diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e813b7df..10ab45df 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -628,6 +628,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1630-reach-mobile-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-node-reach-coverage-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1640-compare-discovery-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 node test-neighbor-map-btn-clip-e2e.js 2>&1 | tee -a e2e-output.txt # #1616: slide-over focus-restore flake-gate. Runs the slide-over # E2E 20 consecutive times against the SAME backend instance so diff --git a/public/analytics.js b/public/analytics.js index 538dde85..961c2738 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2917,7 +2917,7 @@ } // Top hops leaderboard - html += `

Top 20 Longest Hops

`; + html += `

Top 20 Longest Hops

#FromToDistance (${distUnitLabel})TypeObsBest SNRMedian SNRPacket
`; const top20 = data.topHops.slice(0, 20); top20.forEach((h, i) => { const fromLink = h.fromPk ? `${esc(h.fromName)}` : esc(h.fromName || '?'); @@ -2928,13 +2928,13 @@ const pktLink = h.hash ? `${esc(h.hash.slice(0, 12))}…` : '—'; const mapBtn = h.fromPk && h.toPk ? `` : ''; const tsTitle = h.timestamp ? `Best observation: ${h.timestamp}` : ''; - html += ``; + html += ``; }); html += `
#FromToDistance (${distUnitLabel})TypeObsBest SNRMedian SNRPacket
${i+1}${fromLink}${toLink}${formatDistance(h.dist)}${esc(h.type)}${obs}${bestSnr}${medianSnr}${pktLink}${mapBtn}
${i+1}${fromLink}${toLink}${formatDistance(h.dist)}${esc(h.type)}${obs}${bestSnr}${medianSnr}${pktLink}${mapBtn}
`; // Top paths if (data.topPaths.length) { - html += `

Top 10 Longest Multi-Hop Paths

`; + html += `

Top 10 Longest Multi-Hop Paths

#Total Distance (${distUnitLabel})HopsRoutePacket
`; data.topPaths.slice(0, 10).forEach((p, i) => { const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → '); const pktLink = p.hash ? `${esc(p.hash.slice(0, 12))}…` : '—'; @@ -2943,7 +2943,7 @@ p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); }); if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); } const mapBtn = pathPks.length >= 2 ? `` : ''; - html += ``; + html += ``; }); html += `
#Total Distance (${distUnitLabel})HopsRoutePacket
${i+1}${formatDistance(p.totalDist)}${p.hopCount}${route}${pktLink}${mapBtn}
${i+1}${formatDistance(p.totalDist)}${p.hopCount}${route}${pktLink}${mapBtn}
`; } diff --git a/public/nodes.js b/public/nodes.js index a4d856c8..f68b658f 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -384,7 +384,7 @@ '' + renderNodeTimestampHtml(nb.last_seen) + '' + '' + distanceCell + '' + '' + conf.icon + '' + - '' + showOnMap + '' + + '' + showOnMap + '' + ''; }).join(''); } @@ -398,7 +398,7 @@ 'Obs' + 'Last Seen' + 'Distance' + - 'Conf' + + 'Conf' + '' + '' + renderNeighborRows(neighbors, limit) + ''; } diff --git a/public/style.css b/public/style.css index 863e9a95..d3e49aec 100644 --- a/public/style.css +++ b/public/style.css @@ -1104,6 +1104,30 @@ img.brand-logo { padding: 3px 4px; text-align: center; } +/* Trailing action column (row buttons: neighbours "Map", analytics "View on + * map"). These columns carry an empty , so they contribute nothing to the + * width distribution, and the global `.data-table td { max-width: 0 }` above + * zeroes their max-content contribution too — the auto table algorithm then + * hands them whatever is left over, which is routinely narrower than the + * button they contain, and `overflow: hidden` silently clips it. Measured on + * the node Neighbours table: 40px of column for a 53px button at a 1440px + * viewport, 12px in the 420px split panel — clipped on every row at every + * desktop width below ~2000px. + * + * `width: 1%` is the shrink-to-content idiom for auto-layout tables: the + * column takes its min-content width and the data columns absorb the rest. + * `max-width: none` opts back out of the zeroing; `overflow: visible` means a + * future miscalculation shows as a visible overhang rather than a silent crop. + */ +.data-table th.col-action, +.data-table td.col-action { + width: 1%; + max-width: none; + overflow: visible; + white-space: nowrap; + text-align: right; +} + /* #1415 / #1458 / #1461 — mobile packets UX overhaul. * NOTE: hiding the first dt/dd pair below assumes Payload Type is the * first
in .detail-meta (per packets.js renderDetail). Keep that diff --git a/test-neighbor-map-btn-clip-e2e.js b/test-neighbor-map-btn-clip-e2e.js new file mode 100644 index 00000000..59d04cee --- /dev/null +++ b/test-neighbor-map-btn-clip-e2e.js @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/* Trailing action-column clip — node Neighbours "Map" button, analytics + * "View on map" button. + * + * Symptom (operator report, 2026-08-30): on the node detail page the + * Neighbours table's "Map" button rendered as a cropped "◎ M" on every row. + * Unaffected by browser, browser version, zoom level or incognito — because + * it is a pure layout bug, not a rendering one. + * + * Root cause: the action column carries an empty , so it contributes + * nothing to the table's width distribution, and the global + * .data-table td { max-width: 0; overflow: hidden; } + * zeroes its max-content contribution as well. The auto table algorithm + * therefore hands that column only whatever space is left over, which is + * narrower than the button it holds, and `overflow: hidden` crops the + * remainder without any scrollbar to reveal it. Measured before the fix: + * + * viewport column button rows clipped + * 1024px 28px 53px all + * 1440px 40px 53px all + * 1920px 54px 53px all (padding pushes it over) + * 420px split panel: 12px column — 47 of 53px cropped + * + * Fix: `.data-table th.col-action, .data-table td.col-action` opts the + * column out of the zeroing (`max-width: none`), sizes it to its content + * (`width: 1%`) and stops it cropping (`overflow: visible`); nodes.js and + * analytics.js tag their action / with it. + * + * This test does not need a running server: the bug lives entirely in + * public/style.css plus the markup contract, so it renders the real + * stylesheet against the real emitted row markup via setContent(). + * + * Mutation guard: dropping the `.col-action` class from either the or + * the , or removing `max-width: none` / `width: 1%` from the rule, must + * make the geometry assertions below fail. + */ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert'); +const { chromium } = require('playwright'); + +const CSS = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8'); +const NODES_JS = fs.readFileSync(path.join(__dirname, 'public', 'nodes.js'), 'utf8'); +const ANALYTICS_JS = fs.readFileSync(path.join(__dirname, 'public', 'analytics.js'), 'utf8'); + +const HEIGHT = 900; +// The bug's window: below ~640px the table finally overflows and the +// container scrolls (so nothing is cropped); above ~2000px the leftover +// space happens to exceed the button. Everything between was broken. +const WIDTHS = [1024, 1280, 1366, 1440, 1600, 1920]; + +// A neighbour row exactly as renderNeighborRows() emits it (nodes.js). +function neighborRow(name, role, score, count, seen, dist) { + return '' + + `${name}` + + `${role}` + + `${score}` + + `${count}` + + `${seen}` + + `${dist}` + + '●' + + ' ' + + ''; +} + +const ROWS = [ + ['TREX-SBC Room Observer', 'room', '1.00', '12028', '4m ago', '5.5 mi'], + ['SFO-SF-POTRO-RE-8C09', 'repeater', '1.00', '5965', '34m ago', '1.1 mi'], + ['alameda_shoreline_repea', 'repeater', '0.99', '140', '2h ago', '16.0 mi'], + ['Dublin Ridge', 'repeater', '0.97', '138', '2h ago', '17.7 mi'], + ['SLHills Repeater', 'repeater', '0.85', '98', '2h ago', '0.8 mi'], + ['TH-00 San Carlos', 'repeater', '0.80', '86', '3h ago', '9.2 mi'], +].map((r) => neighborRow(...r)).join(''); + +const TABLE = + '' + + '' + + '' + + '' + + '' + ROWS + '
NeighborRoleScoreObsLast SeenDistanceConf
'; + +// Both real hosts: the full-screen node view and the ~420px split panel. +const SHELLS = { + 'node-fullscreen': `
+
+

Neighbors (${ROWS.length})

${TABLE}
+
`, + 'split-panel': `
+
+
+

Neighbors

${TABLE}
+
`, +}; + +function page(shell) { + return ` +${shell}`; +} + +let passes = 0; +let failures = 0; + +function check(fn) { + try { fn(); passes++; } catch (err) { console.error(`FAIL ${err.message}`); failures++; } +} + +// ---- Static guards: the markup contract the CSS rule depends on ---------- + +check(() => { + assert.ok( + /\.data-table th\.col-action,\s*\n\s*\.data-table td\.col-action\s*\{[^}]*max-width:\s*none[^}]*\}/.test(CSS), + '.col-action rule in public/style.css must set `max-width: none` (opts out of the global `.data-table td { max-width: 0 }` that starves the column)', + ); + console.log('PASS style.css: .col-action sets max-width: none'); +}); + +check(() => { + const rule = CSS.match(/\.data-table th\.col-action,\s*\n\s*\.data-table td\.col-action\s*\{([^}]*)\}/); + assert.ok(rule, '.col-action rule not found in public/style.css'); + assert.ok(/width:\s*1%/.test(rule[1]), '.col-action must set `width: 1%` (shrink-to-content for auto-layout tables)'); + assert.ok(/overflow:\s*visible/.test(rule[1]), '.col-action must set `overflow: visible` so a miscalculation overhangs visibly instead of cropping silently'); + console.log('PASS style.css: .col-action sets width: 1% and overflow: visible'); +}); + +check(() => { + assert.ok( + NODES_JS.includes('') && NODES_JS.includes("'' + showOnMap"), + 'public/nodes.js renderNeighborTable/renderNeighborRows must tag BOTH the action and with class="col-action"', + ); + console.log('PASS nodes.js: neighbour action and carry .col-action'); +}); + +check(() => { + const ths = (ANALYTICS_JS.match(//g) || []).length; + const tds = (ANALYTICS_JS.match(/\$\{mapBtn\}<\/td>/g) || []).length; + assert.strictEqual(ths, 2, `public/analytics.js must tag 2 action (Top Hops + Top Paths) with .col-action, found ${ths}`); + assert.strictEqual(tds, 2, `public/analytics.js must tag 2 action with .col-action, found ${tds}`); + console.log('PASS analytics.js: both map-button columns carry .col-action'); +}); + +// ---- Geometry: nothing is cropped at any desktop width ------------------- + +async function main() { + let browser; + try { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + } catch (err) { + if (process.env.CHROMIUM_REQUIRE === '1') { + console.error(`test-neighbor-map-btn-clip-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`); + process.exit(1); + } + console.log(`test-neighbor-map-btn-clip-e2e.js: SKIP geometry (Chromium unavailable: ${err.message.split('\n')[0]})`); + console.log(`test-neighbor-map-btn-clip-e2e.js: ${passes} passed, ${failures} failed`); + process.exit(failures > 0 ? 1 : 0); + } + + const ctx = await browser.newContext(); + const p = await ctx.newPage(); + p.setDefaultTimeout(15000); + + for (const [shellName, shell] of Object.entries(SHELLS)) { + for (const w of WIDTHS) { + await p.setViewportSize({ width: w, height: HEIGHT }); + await p.setContent(page(shell), { waitUntil: 'load' }); + + const probe = await p.evaluate(() => { + const rows = Array.from(document.querySelectorAll('#host tbody tr')); + let worst = 0; + let clipped = 0; + let colWidth = 0; + let btnWidth = 0; + for (const tr of rows) { + const td = tr.querySelector('td.col-action'); + const btn = td && td.querySelector('.neighbor-show-map'); + if (!btn) continue; + colWidth = td.getBoundingClientRect().width; + btnWidth = btn.getBoundingClientRect().width; + const over = btn.getBoundingClientRect().right - td.getBoundingClientRect().right; + if (over > 0.5) { clipped++; if (over > worst) worst = over; } + } + return { rows: rows.length, clipped, worst, colWidth, btnWidth }; + }); + + const tag = `${shellName} vw=${w}`; + check(() => { + assert.strictEqual( + probe.clipped, 0, + `${tag}: ${probe.clipped}/${probe.rows} Map buttons cropped by their cell (worst ${probe.worst.toFixed(1)}px; column ${probe.colWidth.toFixed(1)}px vs button ${probe.btnWidth.toFixed(1)}px)`, + ); + console.log(`PASS ${tag}: column ${probe.colWidth.toFixed(1)}px holds ${probe.btnWidth.toFixed(1)}px button, 0/${probe.rows} cropped`); + }); + } + } + + await browser.close(); + console.log(`\ntest-neighbor-map-btn-clip-e2e.js: ${passes} passed, ${failures} failed`); + if (failures > 0) process.exit(1); +} + +main().catch((err) => { + console.error('test-neighbor-map-btn-clip-e2e.js: ERROR', err); + process.exit(1); +});