From a4a447acea7af945efcba01236ab9eeda319a8b4 Mon Sep 17 00:00:00 2001 From: Sylvain Rabot Date: Sat, 3 Oct 2026 12:15:52 +0200 Subject: [PATCH] fix(packets): pack short columns, one expand arrow, Full Names toggle (#2090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packets table wastes most of its width on short columns, draws two arrows on grouped rows, and cuts every observer and hop name short. This fixes all three and adds a way to read the full path when it does not fit. ## Why the columns were wide `makeColumnsResizable` measured each column once on load and stored the widths as **percentages** of the table. Percentages grow with the screen: on a 2000px display "17s ago" sat in a 180px column. The measurement also read the virtual-scroll spacer row — a single `colspan` cell as wide as the table — as column 0, so the expand column came out 160px wide locally and about 950px on a live instance with grouped rows. Measured on the fixture at 2000px: | Column | Before | After | |---|---|---| | Expand | 162–230px | 32px | | Time | 111px | 67px | | Size | 64px | 46px | | HB | 44px | 29px | | Scope | 80px | 48px | | Path / Details | ~180px each | ~650px each | ## The change **Column packing.** A new `fitColumnsToContent` (`app.js`) sizes every column except Path and Details to its widest cell, in px; those two split what is left. Every visible column gets an explicit width and the table gets their sum: a spacer row keeps a slot for each hidden column, and leaving any column `auto` hands those slots a share of the slack as a blank strip at the right edge. When space is short (detail panel open, narrow window) the widest fixed columns give way first, then Path and Details shrink to 60px, and only then does the wrapper scroll. The split itself is a pure function, `distributeColumnWidths`, with unit tests. It re-lays out when the container resizes (detail panel), when TableResponsive hides or reveals columns (a new `table-columns-changed` event), and after sorting or a column toggle. After each render `grow()` widens columns for the rows just inserted and never narrows them, so nothing jitters while scrolling. Drag handles store px; double-clicking one resets it. Path and Details have no handle. `makeColumnsResizable`, still used by the nodes, observers and analytics tables, now skips `colspan` rows when measuring; other rows are measured as before. **One expand arrow.** A CSS `::before` "▶" sat next to the SVG caret, which pointed up when collapsed. The glyph is gone and the caret points right, then down. **Full Names.** A toolbar toggle next to Hex Paths shows observer and hop names untruncated. It is saved per browser; a link can carry `fullNames=1`, which applies to that page without overwriting the visitor's saved preference. Paths stay on one line: the virtual scroller positions rows at one fixed height, so wrapping would make scrolling jump. **The "+N" path pill, and a full-path popover.** Hops past the column edge were meant to go behind a "+N" pill (#1124), but the pill was appended after the overflowing chips and so sat past the clipped edge: it never showed. It is now sticky at the right edge, opaque on hover, and counts hop chips only (it used to count warning icons too). Hovering or focusing it shows the full path, one hop per line and numbered; click, Enter or Space pins it until an outside click or Escape, and it closes when the page changes or its row scrolls away. Clicking the pill no longer selects the row underneath. **Two related fixes.** - Rows drawn before `/api/observers` resolves show raw 64-char pubkeys. `loadObservers` now re-renders and re-measures, instead of leaving Observer at the pubkey's width (~600px under Full Names). - Group child rows indented every cell by 20px, which misaligned them with the headers and widened every column on expand. Only the Time cell is indented now. Anyone who had dragged packet columns gets the new defaults once: the old percentage key is dropped. ## Performance `grow()` runs after every `renderVisibleRows`, so it is on the hot path. Measured in Chromium on the fixture, 300 iterations, three runs: | Case | Cost | |---|---| | Full render, 63 rows measured | +0.88–0.93ms over the forced reflow alone | | Scroll step (incremental render) | ~73 `Range` rect reads, only the inserted rows | | Large jump / re-render | ~569 rect reads | Before narrowing `grow()` to inserted rows, one scroll step with 92 rendered rows made 5,472 rect reads. The DOM row count is bounded by the virtual-scroll window, so the cost does not grow with the packet count. `distributeColumnWidths` is a binary search over at most ~10 columns. ## Tests - `tests/e2e/test-packets-compact-columns-e2e.js`, 16 cases: short columns stay under fixed px bounds at 1920px; Path and Details take the freed width; hiding both still fills the wrapper; no blank strip and no horizontal scroll, with and without the detail panel, at 1920, 1100, 1000 and 375px; responsive reveal and re-hide refit; stale percentage widths dropped; drag follows the pointer and double-click resets; expanding a group leaves widths alone; Full Names untruncates, keeps paths on one line, persists and round-trips through the URL; the Observer column shrinks back when observer names arrive late; the pill popover lists every hop vertically, closes when the pointer leaves, pins on click without selecting the row, closes on Escape and on navigation, and opens on keyboard focus. Registered in `scripts/non-unit-tests.json` and `deploy.yml`. - On `master` the suite fails where it should, and restoring each fix's old behaviour (child-row indent, the late-observer refit) turns its case red. - `tests/unit/test-packets.js`: `distributeColumnWidths` (room to spare, rounding, widest-first capping, floors, dragged columns, the 60px minimum, overflow), the single chevron and no `::before` glyph, Full Names on flat and grouped observer cells, and the Full Names CSS. - `tests/unit/test-xss-escape-sinks.js` now builds the real `obsCellName` from source: escaping moved into it. Removing its `escapeHtml` turns both observer-cell tests red. - `test-issue-1189-composed-cell.js` and `test-issue-2012-clear-filters-selection.js` evaluate packets.js fragments with injected helpers and needed `obsCellName` / `showFullNames` added. - Existing packets E2E suites pass on this branch and on master against a CI-prepared fixture: scope column, #1128 (layout and multi-viewport), #1122, #1188, #1648, table sort, #1692, #1486. - Unit: 182 of 183 suites pass locally; `test-issue-1956-release-routing.js` could not create a temp directory in my sandbox and is unrelated. ## Browser validation Checked in Chromium against the fixture at 2000, 1920, 1280 (panel open and closed), 1100 and 375px, and with the seeded grouped row expanded and collapsed. Screenshots taken at each step; the table fills its container exactly, grouped rows show one arrow, and the pill popover lists full names. ## Not done, deliberately - **No width cap on Observer under Full Names.** A cap brings back the truncation the toggle removes, and Observer is already the first column to give way when space is short. - **Customizer.** The floors (60px flex minimum, 64px shrink floor, 32px expand, 58px Rpt) are hardcoded, per AGENTS rule 8; exposing them in the customizer can follow. - **Wrapping paths.** Rejected because of the fixed row height above; the pill popover is the way to read a long path. --- .eslintrc.json | 1 + .github/workflows/deploy.yml | 2 + public/app.js | 310 ++++++++++- public/packets.js | 297 ++++++++--- public/style.css | 51 +- scripts/non-unit-tests.json | 1 + tests/e2e/test-analytics-fluid-charts.js | 10 +- tests/e2e/test-issue-1281-location-row-e2e.js | 20 +- tests/e2e/test-packets-compact-columns-e2e.js | 498 ++++++++++++++++++ tests/unit/test-issue-1189-composed-cell.js | 1 + ...test-issue-2012-clear-filters-selection.js | 5 +- tests/unit/test-packets.js | 189 ++++++- tests/unit/test-xss-escape-sinks.js | 85 +-- 13 files changed, 1363 insertions(+), 107 deletions(-) create mode 100644 tests/e2e/test-packets-compact-columns-e2e.js diff --git a/.eslintrc.json b/.eslintrc.json index ab6e2ded..2652f3ad 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -22,6 +22,7 @@ "EXTERNAL_URLS": "readonly", "FAV_KEY": "readonly", "FilterUX": "readonly", + "fitColumnsToContent": "readonly", "GestureHints": "readonly", "HEALTH_THRESHOLDS": "readonly", "HashColor": "readonly", diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9f859fb2..8012b477 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -705,6 +705,8 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-marker-outline-weight.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-node-reach-e2e.js ===" CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-node-reach-e2e.js 2>&1 | tee -a e2e-output.txt + echo "=== E2E SUITE: test-packets-compact-columns-e2e.js ===" + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-packets-compact-columns-e2e.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-packets-scope-column.js ===" CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-packets-scope-column.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-path-inspector-e2e.js ===" diff --git a/public/app.js b/public/app.js index 28b97b39..75a8dc3a 100644 --- a/public/app.js +++ b/public/app.js @@ -1987,8 +1987,7 @@ function makeColumnsResizable(tableSelector, storageKey) { if (!widths) { // Measure actual max content width per column by scanning visible rows - const tbody = table.querySelector('tbody'); - const rows = tbody ? Array.from(tbody.querySelectorAll('tr')).slice(0, 30) : []; + const rows = measurableRows(table); // Temporarily set auto layout to measure table.style.tableLayout = 'auto'; @@ -2105,3 +2104,310 @@ function makeColumnsResizable(tableSelector, storageKey) { }); } // end addResizeHandles } + +/** + * Body rows that can be measured column by column. Virtual-scroll spacers + * and "loading" rows are a single colspan cell; indexing their children by + * column puts the spacer's full table width into column 0. + */ +function measurableRows(table, limit) { + const tbody = table.querySelector('tbody'); + if (!tbody) return []; + return Array.from(tbody.children) + .filter(r => !Array.prototype.some.call(r.children, c => c.colSpan > 1)) + .slice(0, limit || 30); +} + +let _cellMeasureRange = null; + +/** + * Width a cell needs to show its content on one line, padding included. + * Measured from the laid-out content, so it works under table-layout: fixed + * without switching the table to auto. Resize handles and the "+N hidden" + * pill are absolutely positioned or transient and are left out. + */ +function cellContentWidth(cell) { + const range = _cellMeasureRange || (_cellMeasureRange = document.createRange()); + // Span from the leftmost to the rightmost node, so margins between them + // (the IATA badge's margin-left) count too. + let left = Infinity; + let right = -Infinity; + for (const n of cell.childNodes) { + if (n.nodeType === 1 && n.matches('.col-resize-handle, .col-hidden-pill')) continue; + range.selectNode(n); + const r = range.getBoundingClientRect(); + if (!r.width) continue; + left = Math.min(left, r.left); + right = Math.max(right, r.right); + } + const w = right > left ? right - left : 0; + const cs = getComputedStyle(cell); + return Math.ceil(w + parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight)); +} + +/** + * Split `avail` px between content-sized columns and flex columns. + * + * Each fixed column keeps its content width `w` when there is room. When + * there is not, the widest ones are capped at a common level (water-filling) + * so the flex columns still get `flexMin` each, but no column goes below its + * `floor`. Flex columns share what is left, the rounding remainder going to + * the last one. When even the floors leave less than `flexMin`, the flex + * columns shrink further, down to `flexHardMin`; below that `total` exceeds + * `avail` and the caller's wrapper scrolls. With no flex column (both hidden + * by the user), the last fixed column takes the spare width, so the table + * still fills `avail`. + * + * @param {{w: number, floor: number}[]} fixed + * @param {number} flexCount + * @param {number} avail + * @param {number} flexMin + * @param {number} [flexHardMin=flexMin] + * @returns {{fixed: number[], flex: number[], total: number}} + */ +function distributeColumnWidths(fixed, flexCount, avail, flexMin, flexHardMin) { + const hardMin = flexHardMin == null ? flexMin : Math.min(flexHardMin, flexMin); + const budget = avail - flexCount * flexMin; + const sumAt = cap => fixed.reduce((s, c) => s + Math.max(c.floor, Math.min(c.w, cap)), 0); + let cap = Infinity; + if (sumAt(Infinity) > budget) { + // Largest integer cap whose total fits; sumAt is monotonic in cap. + let lo = 0; + let hi = Math.max(0, ...fixed.map(c => c.w)); + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (sumAt(mid) <= budget) lo = mid; else hi = mid - 1; + } + cap = lo; + } + const widths = fixed.map(c => Math.max(c.floor, Math.min(c.w, cap))); + const fixedSum = widths.reduce((s, w) => s + w, 0); + const spare = Math.max(0, avail - fixedSum); + const share = flexCount ? Math.max(hardMin, Math.floor(spare / flexCount)) : 0; + const flexW = []; + for (let i = 0; i < flexCount; i++) flexW.push(share); + if (flexCount) flexW[flexCount - 1] += Math.max(0, spare - share * flexCount); + else if (widths.length) widths[widths.length - 1] += spare; + const total = widths.reduce((s, w) => s + w, 0) + flexW.reduce((s, w) => s + w, 0); + return { fixed: widths, flex: flexW, total }; +} + +/** + * Pixel column sizing for dense tables: every column not listed in + * `opts.flex` is sized to its content, and the flex columns split whatever + * width the container has left. makeColumnsResizable stores percentages, + * which inflate a "17s ago" column to 180px on a wide screen; this keeps + * short columns short at every width. + * + * Every visible column gets an explicit px width and the table gets their + * sum as its width. Leaving the flex columns auto would hand a share of the + * slack to phantom columns too: a colspan spacer row keeps a slot for each + * hidden column, and that slot renders as a blank strip at the right edge. + * When the container is too narrow, the widest content-sized columns give + * way first (down to `opts.shrinkFloor`, their text ellipsised) so the flex + * columns keep `opts.flexMin`; then the flex columns shrink to + * `opts.flexHardMin`; past that the wrapper scrolls. A header's CSS + * max-width (Scope's 110px) caps its measured width. + * + * `opts.min` sets floors by column class, for columns whose widest content + * may not be on screen at measure time (the expand caret only appears on + * grouped rows). + * + * Content-sized columns have drag handles; dragged widths are saved to + * localStorage in px, keyed by column class, and give way under pressure + * like measured ones. Double-clicking a handle forgets its width. Flex + * columns have no handle: they always share the remaining width. + * + * Re-measures on its own when the container resizes (the detail panel) and + * when the table fires `table-columns-changed` (TableResponsive hiding or + * revealing columns). Call refit() after anything else that changes cell + * content width: column toggles, name display modes, sorting (the sort + * arrow moves to another header). Call grow() after rows render, with the + * inserted rows when only some are new. + * + * @param {string} tableSelector + * @param {string} storageKey + * @param {{flex: string[], flexMin?: number, flexHardMin?: number, shrinkFloor?: number, min?: Object}} opts + * @returns {{refit: function(): void, grow: function(Element[]=): void, destroy: function(): void}|null} + */ +function fitColumnsToContent(tableSelector, storageKey, opts) { + const table = document.querySelector(tableSelector); + if (!table) return null; + const ths = Array.from(table.querySelectorAll('thead tr:first-child th')); + if (ths.length < 2) return null; + const wrap = table.parentElement; + const flex = new Set(opts.flex); + const flexMin = opts.flexMin || 120; + const flexHardMin = opts.flexHardMin || 60; + const mins = opts.min || {}; + const shrinkFloor = opts.shrinkFloor || 64; + const MIN_DRAG = 28; + const colKey = th => Array.from(th.classList).find(c => c.startsWith('col-') && c !== 'col-hidden'); + + let dragged = {}; + try { dragged = JSON.parse(localStorage.getItem(storageKey)) || {}; } catch { dragged = {}; } + if (typeof dragged !== 'object' || Array.isArray(dragged)) dragged = {}; + for (const k of Object.keys(dragged)) { + if (flex.has(k) || !Number.isFinite(dragged[k]) || dragged[k] < MIN_DRAG) delete dragged[k]; + } + function saveDragged() { + try { localStorage.setItem(storageKey, JSON.stringify(dragged)); } catch { /* quota / private mode */ } + } + + table.style.tableLayout = 'fixed'; + + // Content widths of the visible fixed columns, from the last measure(). + let natural = new Map(); + let borderPx = 0; + + // Content widths per visible fixed column over `rows` (default: every + // rendered row; virtual scroll already bounds how many exist). + function measure(rows) { + rows = (rows || measurableRows(table, Infinity)) + .filter(r => !Array.prototype.some.call(r.children, c => c.colSpan > 1)); + const next = new Map(); + ths.forEach((th, i) => { + const key = colKey(th); + if (th.offsetParent === null || dragged[key] || flex.has(key)) return; + let w = Math.max(mins[key] || 0, cellContentWidth(th)); + rows.forEach(r => { if (r.children[i]) w = Math.max(w, cellContentWidth(r.children[i])); }); + const maxW = parseFloat(getComputedStyle(th).maxWidth); + if (Number.isFinite(maxW)) w = Math.min(w, maxW); + next.set(th, w); + }); + return next; + } + + function layout() { + const fixedThs = []; + const fixedCols = []; + const flexThs = []; + // The last visible column absorbs the slack when no flex column is + // shown; `fit-last` hides its drag handle, which overhangs the table's + // right edge by a few px and would give the wrapper a scrollbar. + const visible = ths.filter(th => th.offsetParent !== null); + ths.forEach(th => th.classList.toggle('fit-last', th === visible[visible.length - 1])); + ths.forEach(th => { + if (th.offsetParent === null) return; + const key = colKey(th); + const w = dragged[key] || natural.get(th); + if (flex.has(key)) flexThs.push(th); + else if (w) { + fixedThs.push(th); + fixedCols.push({ w, floor: Math.min(w, Math.max(mins[key] || 0, shrinkFloor)) }); + } else th.style.width = ''; + }); + const apply = () => { + const d = distributeColumnWidths(fixedCols, flexThs.length, wrap.clientWidth - borderPx, flexMin, flexHardMin); + fixedThs.forEach((th, i) => { th.style.width = d.fixed[i] + 'px'; }); + flexThs.forEach((th, i) => { th.style.width = d.flex[i] + 'px'; }); + table.style.width = d.total + 'px'; + return d.total; + }; + // Collapsed borders (the row's coloured left edge) render a few px + // beyond the column sum. Learn how many on the first pass and lay out + // again without them, so a table that should fit grows no scrollbar. + // Bounded: anything bigger is not a border and must not eat the width. + const total = apply(); + const extra = Math.min(8, Math.max(0, table.offsetWidth - total)); + if (extra !== borderPx) { borderPx = extra; apply(); } + } + + function refit() { + if (!table.isConnected) return; + natural = measure(); + layout(); + } + + // After new rows render: widen any column whose content outgrew it (rows + // age from "9m ago" to "10m ago", scrolling brings longer names), but never + // narrow one, so columns do not jitter as rows scroll past. Pass just the + // rows that were inserted when the others are unchanged (virtual-scroll + // steps): measuring is ~10 rects per cell. + function grow(rows) { + if (!table.isConnected) return; + if (rows && !rows.length) return; + let changed = false; + measure(rows).forEach((w, th) => { + if (w > (natural.get(th) || 0)) { natural.set(th, w); changed = true; } + }); + if (changed) layout(); + } + + // Its own flag: `resizable` is makeColumnsResizable's already-wired guard. + if (!table.dataset.fitColumns) { + table.dataset.fitColumns = '1'; + ths.forEach(th => { + const key = colKey(th); + if (flex.has(key)) return; + const handle = document.createElement('div'); + handle.className = 'col-resize-handle'; + handle.title = 'Drag to resize, double-click to fit the content'; + handle.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const startW = th.offsetWidth; + handle.classList.add('active'); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + function onMove(e2) { + // Re-lay out on every move so the flex columns give the width + // back: setting only this lets fixed layout spread the + // difference over every column, and the edge lags the pointer. + dragged[key] = Math.max(MIN_DRAG, startW + e2.clientX - startX); + layout(); + } + function onUp() { + handle.classList.remove('active'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + if (dragged[key]) saveDragged(); + refit(); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + handle.addEventListener('dblclick', (e) => { + e.preventDefault(); + e.stopPropagation(); + delete dragged[key]; + saveDragged(); + refit(); + }); + // A handle click must not reach the header's sort handler. + handle.addEventListener('click', (e) => e.stopPropagation()); + th.appendChild(handle); + }); + } + + // The container changes width when the detail panel opens or is dragged: + // redistribute only. Responsive hiding changes which columns exist. + // Coalesced to one layout per frame: dragging the detail-panel divider + // resizes the wrapper on every pointer move, and layout() forces a + // synchronous reflow. + let lastWrapW = wrap.clientWidth; + let layoutRaf = 0; + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => { + if (layoutRaf) return; + layoutRaf = requestAnimationFrame(() => { + layoutRaf = 0; + if (!table.isConnected || wrap.clientWidth === lastWrapW) return; + lastWrapW = wrap.clientWidth; + layout(); + }); + }) : null; + if (ro) ro.observe(wrap); + table.addEventListener('table-columns-changed', refit); + + function destroy() { + if (ro) ro.disconnect(); + if (layoutRaf) cancelAnimationFrame(layoutRaf); + table.removeEventListener('table-columns-changed', refit); + } + + refit(); + return { refit, grow, destroy }; +} diff --git a/public/packets.js b/public/packets.js index 5abee67a..57ed04b9 100644 --- a/public/packets.js +++ b/public/packets.js @@ -26,6 +26,17 @@ function thsOf(table) { return Array.from(table.querySelectorAll('thead > tr > th')); } + // Fire `table-columns-changed` on the table when the set of hidden columns + // differs from the last call, so column sizing (fitColumnsToContent) can + // re-measure. apply() runs on every tbody mutation; most leave the set + // alone and dispatch nothing. + function notifyIfChanged(table) { + const sig = thsOf(table).map(th => th.classList.contains(HIDDEN_CLASS) ? '1' : '0').join(''); + if (table.__trHiddenSig === sig) return; + table.__trHiddenSig = sig; + table.dispatchEvent(new CustomEvent('table-columns-changed')); + } + function clearHidden(table) { table.querySelectorAll('.' + HIDDEN_CLASS).forEach(el => el.classList.remove(HIDDEN_CLASS)); const pill = table.querySelector('.' + PILL_CLASS); @@ -55,6 +66,7 @@ if (table[REVEAL_FLAG]) { // user explicitly requested reveal — clear hidden state and skip clearHidden(table); + notifyIfChanged(table); return; } clearHidden(table); @@ -101,6 +113,7 @@ ev.preventDefault(); table[REVEAL_FLAG] = true; clearHidden(table); + notifyIfChanged(table); // Add a small "hide again" affordance after reveal so the user isn't stuck. const rehide = document.createElement('button'); rehide.type = 'button'; @@ -127,6 +140,7 @@ }); host.appendChild(pill); } + notifyIfChanged(table); } // Track tables we've wired up so resize triggers re-apply. @@ -662,6 +676,12 @@ if (!o) return id; return o.name; } + // Observer name as the table cell shows it: cut to `maxLen` characters + // unless the Full Names toggle is on. Escaped, ready for innerHTML. + function obsCellName(id, maxLen) { + const name = obsNameOnly(id); + return escapeHtml(showFullNames ? name : truncate(name, maxLen)); + } // #1189 R1 mesh-operator feedback: in a grouped row the old cell showed ONE // observer's IATA + `+N` — operators couldn't tell whether the N additional // observers were SAME-region (redundant copies) or CROSS-region (interesting @@ -743,6 +763,10 @@ let _packetSortColumn = null; let _packetSortDirection = 'desc'; let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true'; + // "Full Names": show observer and path hop names untruncated (path chips + // lose their 120px cap; hops past the column edge go behind the +N pill). + let showFullNames = localStorage.getItem('meshcore-full-names') === 'true'; + let _colFit = null; // fitColumnsToContent controller for #pktTable var _pendingUrlRegion = null; var DEFAULT_TIME_WINDOW = 15; @@ -756,6 +780,7 @@ if (filters.observer) parts.push('observer=' + encodeURIComponent(filters.observer)); if (filters.channel) parts.push('channel=' + encodeURIComponent(filters.channel)); if (filters._filterExpr) parts.push('filter=' + encodeURIComponent(filters._filterExpr)); + if (showFullNames) parts.push('fullNames=1'); // Sort state (#749) — encode as 'col[:asc]'; default 'time:desc' is omitted. if (_packetSortColumn) { var sortDefault = _packetSortColumn === 'time' && _packetSortDirection === 'desc'; @@ -1181,6 +1206,15 @@ // Read URL params (router strips query from routeParam; read from location.hash) var _initUrlParams = getHashParams(); directObsId = _initUrlParams.get('obs'); + // Full Names is a view mode: a shared link carries it (fullNames=1/0). + // It applies to this page only; the visitor's saved preference is theirs, + // so opening somebody else's link does not overwrite it. + var _urlFullNames = _initUrlParams.get('fullNames'); + if (_urlFullNames === '1' || _urlFullNames === '0') { + showFullNames = _urlFullNames === '1'; + } else { + showFullNames = localStorage.getItem('meshcore-full-names') === 'true'; + } var _urlTimeWindow = Number(_initUrlParams.get('timeWindow')); if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) { savedTimeWindowMin = _urlTimeWindow; @@ -1435,6 +1469,8 @@ if (wsHandler) offWS(wsHandler); wsHandler = null; if (_tableSortInstance) { _tableSortInstance.destroy(); _tableSortInstance = null; } + if (_colFit) { _colFit.destroy(); _colFit = null; } + if (_pathPopoverClose) _pathPopoverClose(false); detachVScrollListener(); clearTimeout(_wsRenderTimer); if (_wsRafId) { cancelAnimationFrame(_wsRafId); _wsRafId = null; } @@ -1478,6 +1514,10 @@ if (typeof _rebuildObserverMenu === 'function') { try { _rebuildObserverMenu(); } catch {} } + // Rows drawn before observers arrived show raw observer ids (a 64-char + // pubkey under Full Names). Redraw them with names and re-measure: + // grow() alone would leave the Observer column at the id's width. + if (_colFit) renderTableRows().then(() => { if (_colFit) _colFit.refit(); }); } catch {} } @@ -1720,10 +1760,11 @@
+
${(window.HopDisplay && HopDisplay.renderPathSymbolsLegend) ? HopDisplay.renderPathSymbolsLegend() : ''}
-
+
@@ -2190,6 +2231,7 @@ }); localStorage.setItem('packets-visible-cols', JSON.stringify(visibleCols)); localStorage.setItem('packets-known-cols', JSON.stringify(COL_DEFS.map(c => c.key))); + if (_colFit) _colFit.refit(); } colMenu.innerHTML = COL_DEFS.map(c => `` @@ -2216,6 +2258,17 @@ renderTableRows(); }); + document.getElementById('fullNamesToggle').addEventListener('click', function () { + showFullNames = !showFullNames; + localStorage.setItem('meshcore-full-names', showFullNames); + this.classList.toggle('active', showFullNames); + pktTable.classList.toggle('pkt-full-names', showFullNames); + updatePacketsUrl(); + // Observer widths follow the names: re-measure once rows are rebuilt + // (grow() alone would never shrink the column back). + renderTableRows().then(() => { if (_colFit) _colFit.refit(); }); + }); + // Node name filter with autocomplete const fNode = document.getElementById('fNode'); const fNodeDrop = document.getElementById('fNodeDropdown'); @@ -2300,7 +2353,7 @@ if (pktBody) { const handler = (e) => { // Let hop links navigate naturally without selecting the row - if (e.target.closest('[data-hop-link]')) return; + if (e.target.closest('[data-hop-link], .path-overflow-pill')) return; const row = e.target.closest('tr[data-action]'); if (!row) return; if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; @@ -2345,7 +2398,18 @@ }); renderTableRows(); - makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths'); + // Short columns (time, hash, size, ...) take the pixels their content + // needs; the rest of the width goes to Path and Details. The old + // percentage layout scaled a "17s ago" column to 180px on wide screens. + try { localStorage.removeItem('meshcore-pkt-col-widths'); } catch (_) {} + if (_colFit) _colFit.destroy(); + _colFit = fitColumnsToContent('#pktTable', 'meshcore-pkt-col-px', { + flex: ['col-path', 'col-details'], + // Floors for content that only grouped rows carry, which may not be + // on screen when the table is measured: the caret and a 2-digit + // "seen N times" badge. + min: { 'col-expand': 32, 'col-rpt': 58 }, + }); // #1056: register fluid-column responsive behavior (drops priority>1 cols // when narrow, shows "+N hidden" pill, reveals on click). Idempotent. if (window.TableResponsive) { @@ -2369,6 +2433,7 @@ sortPacketsArray(); renderTableRows(); updatePacketsUrl(); + if (_colFit) _colFit.refit(); } }); // Apply initial sort state from TableSort @@ -2380,6 +2445,9 @@ } } } + // Re-measure now that the sort arrow is in its header and responsive + // hiding has run. + if (_colFit) _colFit.refit(); } // Build HTML for a single grouped packet row @@ -2415,7 +2483,7 @@ const _grpHashStripe = _hashStripeStyle(p.hash); const _grpStyle = _grpHashStripe + _grpChanStyle; let html = ` - + @@ -2423,7 +2491,7 @@ - + @@ -2458,7 +2526,7 @@ - + @@ -2495,7 +2563,7 @@ - + @@ -2634,6 +2702,8 @@ if (h > 0) { VSCROLL_ROW_HEIGHT = h; _vscrollRowHeightMeasured = true; } } } + if (_colFit) _colFit.grow(); + _closeStalePathPopover(); if (window.__PERF_LOG_RENDER) console.log('[perf] renderVisibleRows: full rebuild %d entries, %.2fms', endIdx - startIdx, performance.now() - _rvr_t0); _finalizePathOverflow(tbody); // #1128 (Bug 1): hop-resolver mutates chip text from hex prefix to a @@ -2658,13 +2728,18 @@ const row = bottomSpacer.previousElementSibling; if (row && row !== topSpacer) row.remove(); } + // Rows inserted below, collected so column fitting measures only them. + const inserted = []; + const collect = (from, to) => { for (let r = from; r && r !== to; r = r.nextElementSibling) inserted.push(r); }; // Prepend rows that scrolled into view at the top if (startIdx < prevStart) { let html = ''; for (let i = startIdx; i < Math.min(prevStart, endIdx); i++) { html += builder(_displayPackets[i], i); } + const oldFirst = topSpacer.nextElementSibling; topSpacer.insertAdjacentHTML('afterend', html); + collect(topSpacer.nextElementSibling, oldFirst); } // Append rows that scrolled into view at the bottom if (endIdx > prevEnd) { @@ -2672,8 +2747,12 @@ for (let i = Math.max(prevEnd, startIdx); i < endIdx; i++) { html += builder(_displayPackets[i], i); } + const oldLast = bottomSpacer.previousElementSibling; bottomSpacer.insertAdjacentHTML('beforebegin', html); + collect(oldLast === topSpacer || !oldLast ? topSpacer.nextElementSibling : oldLast.nextElementSibling, bottomSpacer); } + if (_colFit) _colFit.grow(inserted); + _closeStalePathPopover(); if (window.__PERF_LOG_RENDER) console.log('[perf] renderVisibleRows: incremental head=%d tail=%d, %.2fms', headRowCount, tailRowCount, performance.now() - _rvr_t0); _finalizePathOverflow(tbody); _scheduleReFinalizePathOverflow(tbody); @@ -2696,11 +2775,11 @@ var hostRight = host.getBoundingClientRect().right; if (!hostRight) continue; var hidden = 0; - // Walk pairs of chip + arrow; count chips (not arrows) whose right edge - // is past the host's right edge. + // Count hop chips whose right edge is past the host's right edge. + // Arrows and a hop's warning buttons are not hops of their own. for (var j = 0; j < children.length; j++) { var ch = children[j]; - if (ch.classList.contains('arrow')) continue; + if (!ch.classList.contains('hop')) continue; var r = ch.getBoundingClientRect(); if (r.left >= hostRight || r.right > hostRight + 0.5) hidden++; } @@ -2708,10 +2787,10 @@ var pill = document.createElement('span'); pill.className = 'path-overflow-pill'; pill.textContent = '+' + hidden; - pill.title = hidden + ' more hop' + (hidden === 1 ? '' : 's') + ' — click to view'; + // No native title: hovering shows the full-path popover instead. pill.setAttribute('role', 'button'); pill.setAttribute('tabindex', '0'); - pill.setAttribute('aria-label', hidden + ' more hops'); + pill.setAttribute('aria-label', hidden + ' more hop' + (hidden === 1 ? '' : 's') + ', show the full path'); host.appendChild(pill); } host.dataset.overflowChecked = '1'; @@ -2760,65 +2839,164 @@ } } - // Delegated click for path overflow pills — show popover of full path. + // Split a rendered .path-hops host into hops: each hop is its chip plus + // any warning buttons that follow it, up to the next arrow. The overflow + // pill itself is left out. + function _pathHopSegments(host) { + var segments = []; + var current = ''; + var kids = Array.prototype.slice.call(host.children); + for (var i = 0; i < kids.length; i++) { + var k = kids[i]; + if (k.classList.contains('path-overflow-pill')) continue; + if (k.classList.contains('arrow')) { + if (current) segments.push(current); + current = ''; + continue; + } + current += k.outerHTML; + } + if (current) segments.push(current); + return segments; + } + + // The full path as a vertical list, one hop per line, in path order. Each + // chip keeps its .path-hops styling; the 120px cap is lifted in CSS. + function _buildPathPopover(host) { + var segments = _pathHopSegments(host); + var pop = document.createElement('div'); + pop.className = 'path-popover'; + pop.id = 'pathPopover'; + pop.setAttribute('role', 'tooltip'); + var html = '
Full path · ' + segments.length + ' hop' + (segments.length === 1 ? '' : 's') + '
    '; + for (var i = 0; i < segments.length; i++) { + html += '
  1. ' + (i + 1) + '' + segments[i] + '
  2. '; + } + pop.innerHTML = html + '
'; + return pop; + } + + // Below the pill by default; above it when there is no room below. Kept + // inside the viewport horizontally. + function _positionPathPopover(pop, pill) { + var r = pill.getBoundingClientRect(); + var popH = pop.getBoundingClientRect().height; + var roomBelow = window.innerHeight - r.bottom; + var top = (roomBelow < popH + 12 && r.top > popH + 12) + ? window.scrollY + r.top - popH - 4 + : window.scrollY + r.bottom + 4; + pop.style.top = top + 'px'; + pop.style.left = (window.scrollX + r.left) + 'px'; + var pr = pop.getBoundingClientRect(); + if (pr.right > window.innerWidth - 8) { + pop.style.left = Math.max(8, window.scrollX + window.innerWidth - pr.width - 8) + 'px'; + } + } + + // Set by _wirePathOverflowPopover: closes the open path popover (or only + // one whose pill has left the DOM). The popover lives on , outside + // the table, so neither row re-renders nor page changes remove it. + let _pathPopoverClose = null; + function _closeStalePathPopover() { if (_pathPopoverClose) _pathPopoverClose(true); } + + // Delegated handlers for path overflow pills. Hovering or focusing a pill + // shows the full path; moving into the popover keeps it open so its hop + // links can be clicked. Clicking the pill (or Enter/Space) pins it until + // an outside click or Escape; clicking a pinned pill closes it. function _wirePathOverflowPopover() { if (window.__pathOverflowWired) return; window.__pathOverflowWired = true; + var HOVER_DELAY_MS = 120; + var HIDE_GRACE_MS = 150; var existing = null; + var anchor = null; + var pinned = false; + var showTimer = null; + var hideTimer = null; + _pathPopoverClose = function (onlyIfStale) { + if (!existing) return; + if (onlyIfStale && anchor && anchor.isConnected) return; + dismiss(); + }; + function pillOf(el) { return el && el.closest ? el.closest('.path-overflow-pill') : null; } + function inPopover(el) { return !!(existing && el && existing.contains(el)); } function dismiss() { + clearTimeout(showTimer); + clearTimeout(hideTimer); if (existing) { existing.remove(); existing = null; } + if (anchor) { anchor.removeAttribute('aria-describedby'); anchor = null; } + pinned = false; document.removeEventListener('mousedown', onDoc, true); - document.removeEventListener('keydown', onKey, true); } function onDoc(ev) { - if (existing && !existing.contains(ev.target) && !ev.target.classList.contains('path-overflow-pill')) dismiss(); + if (!inPopover(ev.target) && !pillOf(ev.target)) dismiss(); } - function onKey(ev) { if (ev.key === 'Escape') dismiss(); } - document.addEventListener('click', function(ev) { - var pill = ev.target.closest && ev.target.closest('.path-overflow-pill'); + function open(pill, pin) { + clearTimeout(showTimer); + clearTimeout(hideTimer); + if (existing && anchor === pill) { if (pin) pinned = true; return; } + dismiss(); + var host = pill.closest('.path-hops'); + if (!host || !pill.isConnected) return; + var pop = _buildPathPopover(host); + document.body.appendChild(pop); + _positionPathPopover(pop, pill); + existing = pop; + anchor = pill; + pinned = !!pin; + pill.setAttribute('aria-describedby', pop.id); + setTimeout(function () { document.addEventListener('mousedown', onDoc, true); }, 0); + } + function scheduleHide() { + clearTimeout(showTimer); + if (pinned || !existing) return; + clearTimeout(hideTimer); + hideTimer = setTimeout(dismiss, HIDE_GRACE_MS); + } + function toggle(pill) { + if (existing && anchor === pill && pinned) dismiss(); + else open(pill, true); + } + document.addEventListener('mouseover', function (ev) { + var pill = pillOf(ev.target); + if (pill) { + clearTimeout(hideTimer); + if (existing && anchor === pill) return; + clearTimeout(showTimer); + showTimer = setTimeout(function () { open(pill, false); }, HOVER_DELAY_MS); + } else if (inPopover(ev.target)) { + clearTimeout(hideTimer); + } + }); + document.addEventListener('mouseout', function (ev) { + if (!pillOf(ev.target) && !inPopover(ev.target)) return; + var to = ev.relatedTarget; + if (inPopover(to) || (anchor && pillOf(to) === anchor)) return; + scheduleHide(); + }); + document.addEventListener('focusin', function (ev) { + var pill = pillOf(ev.target); + if (pill) open(pill, false); + }); + document.addEventListener('focusout', function (ev) { + if (pillOf(ev.target) && !inPopover(ev.relatedTarget)) scheduleHide(); + }); + // Capture phase: the table row's own click/keyboard handlers on #pktBody + // would otherwise select the row first. + document.addEventListener('click', function (ev) { + var pill = pillOf(ev.target); if (!pill) return; ev.stopPropagation(); - var host = pill.closest('.path-hops'); - if (!host) return; - dismiss(); - var pop = document.createElement('div'); - pop.className = 'path-popover'; - // Clone all children except the pill, preserving rendered chips/arrows. - var inner = '
Full path (' + (host.children.length) + ' items)
'; - var kids = Array.prototype.slice.call(host.children); - for (var i = 0; i < kids.length; i++) { - if (kids[i].classList.contains('path-overflow-pill')) continue; - inner += kids[i].outerHTML; - } - inner += '
'; - pop.innerHTML = inner; - document.body.appendChild(pop); - var r = pill.getBoundingClientRect(); - // #1128 (Bug 2): position below by default, but flip ABOVE when there - // isn't enough room — keeps the popover anchored to the pill instead of - // hanging arbitrarily over adjacent rows / off-screen. - var pr0 = pop.getBoundingClientRect(); - var popH = pr0.height; - var roomBelow = window.innerHeight - r.bottom; - var top; - if (roomBelow < popH + 12 && r.top > popH + 12) { - top = window.scrollY + r.top - popH - 4; - } else { - top = window.scrollY + r.bottom + 4; - } - var left = window.scrollX + r.left; - pop.style.top = top + 'px'; - pop.style.left = left + 'px'; - var pr = pop.getBoundingClientRect(); - if (pr.right > window.innerWidth - 8) { - pop.style.left = Math.max(8, window.scrollX + window.innerWidth - pr.width - 8) + 'px'; - } - existing = pop; - setTimeout(function() { - document.addEventListener('mousedown', onDoc, true); - document.addEventListener('keydown', onKey, true); - }, 0); - }); + toggle(pill); + }, true); + document.addEventListener('keydown', function (ev) { + if (ev.key === 'Escape' && existing) { dismiss(); return; } + var pill = pillOf(ev.target); + if (!pill || (ev.key !== 'Enter' && ev.key !== ' ')) return; + ev.preventDefault(); + ev.stopPropagation(); + toggle(pill); + }, true); } // Attach/detach scroll listener for virtual scrolling @@ -4220,6 +4398,7 @@ renderTableRows, _setPackets: function(p) { packets = p; }, _setFilter: function(k, v) { filters[k] = v; }, + _setFullNames: function(v) { showFullNames = !!v; }, }; } diff --git a/public/style.css b/public/style.css index 7b95eda7..a0ad8aa3 100644 --- a/public/style.css +++ b/public/style.css @@ -1590,6 +1590,25 @@ body.scroll-locked { overflow: hidden; } * abbreviated 7-char chips + arrow. .path-hops already uses 11px font, so * no chip-level font change is needed here. */ .data-table td.col-path, .data-table th.col-path { min-width: 140px; } +/* Packets columns are sized to their widest cell (fitColumnsToContent in + * app.js). Proportional digits make "20m ago" wider than "13m ago", so a + * column measured on one row clipped the next; fixed-width digits keep + * same-length values the same width. */ +#pktTable td.col-time, #pktTable td.col-size { font-variant-numeric: tabular-nums; } +/* fitColumnsToContent sets every #pktTable column width in px. The shared + * min-width rules on .col-time (clamp 72-108px), .col-path (140px), + * .col-observer (70px) and .col-scope (56px) are written for auto-layout + * tables; Chromium ignores cell min-width under table-layout: fixed, other + * engines need not. Clear them here so the JS widths are the only input. */ +#pktTable th, #pktTable td { min-width: 0; } +/* "Full Names" toggle (packets.js): hop chips keep their whole name. The + * path stays on one line; hops past the edge go behind the "+N" overflow + * pill (_finalizePathOverflow). Wrapping would make rows taller than the + * fixed VSCROLL_ROW_HEIGHT the virtual scroller positions rows with. No cap + * at all, not even 100%: a single name longer than the column must overflow + * so the pill (and its full-path popover) appears, rather than ellipsise + * inside its chip with no way to read it. */ +#pktTable.pkt-full-names .path-hops .hop-named { max-width: none; } .data-table td.col-path { height: 28px; line-height: 22px; overflow: hidden; } /* === Modal === */ @@ -2567,11 +2586,15 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } /* === Grouped Packet Rows === */ .group-header { cursor: pointer; font-weight: 600; } -.group-header td:first-child::before { content: '▶ '; font-size: 10px; color: var(--link-color); transition: transform 0.15s; display: inline-block; } -.group-header.expanded td:first-child::before { content: '▼ '; } +/* The expand chevron is the SVG caret packets.js renders into td.col-expand; + * a text ::before glyph here drew a second arrow next to it. */ +.group-header td.col-expand { color: var(--link-color); } .group-header:hover { background: var(--row-hover); } .group-child { font-size: 12px; } -.group-child td { padding-left: 20px; } +/* Indent only the first data cell: indenting every cell pushed child rows + * out of line with the headers, and made each content-sized column measure + * 14px wider once a group was expanded. */ +.group-child td.col-time { padding-left: 20px; } .group-child.hidden { display: none; } /* === Reduced Motion === */ @@ -2701,6 +2724,10 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } .col-resize-handle:hover::after, .col-resize-handle.active::after { background: var(--accent); opacity: 0.6; } +/* fitColumnsToContent: the last visible column takes whatever width is left, + * so it has nothing to resize against, and its handle (right: -4px) would + * overhang the table and scroll the wrapper sideways. */ +th.fit-last > .col-resize-handle { display: none; } /* Encrypted channels de-emphasized */ button.ch-item.ch-item-encrypted { opacity: 0.5; } @@ -5010,18 +5037,32 @@ body[data-cb-preset="achromat"] { border-radius: 8px; cursor: pointer; user-select: none; + /* The pill is the last flex item after the chips that overflow, so on its + * own it sits past the clipped edge and never shows. Stick it to the + * right edge of .path-hops (overflow: hidden), over the last chip. */ + position: sticky; right: 0; z-index: 1; } -.path-overflow-pill:hover { color: var(--text); background: var(--bg-hover, var(--surface)); } +/* Opaque on hover: the pill sits over the last chip, and --bg-hover is a + * translucent tint that let the chip show through. */ +.path-overflow-pill:hover { color: var(--text); background: var(--row-hover); border-color: var(--accent); } .path-popover { position: absolute; z-index: var(--z-popover); background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); - padding: 8px 10px; max-width: min(360px, calc(100vw - 32px)); max-height: 240px; overflow: auto; + padding: 8px 10px; max-width: min(480px, calc(100vw - 32px)); max-height: min(420px, 60vh); overflow: auto; font-family: var(--mono); font-size: 12px; } .path-popover .path-popover-title { font-family: var(--font); font-size: 11px; color: var(--text-muted); margin-bottom: 4px; } .path-popover .hop, .path-popover .hop-named { white-space: nowrap; } +/* Full path, one hop per line (packets.js _buildPathPopover). Each hop sits + * in its own .path-hops so chips keep their table styling, minus the 120px + * cap: the popover exists to show the names the table had to cut. */ +.path-popover-list { list-style: none; margin: 0; padding: 0; } +.path-popover-list li { display: flex; align-items: center; gap: 8px; padding: 2px 0; } +.path-popover-idx { min-width: 2ch; text-align: right; color: var(--text-muted); font-size: 11px; font-variant-numeric: tabular-nums; } +.path-popover .path-hops { max-width: none; } +.path-popover .path-hops .hop-named { max-width: none; } .fux-popover-header { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--surface); } .fux-popover-close { background: transparent; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer; padding: 0 4px; } .fux-popover-close:hover { color: var(--text); } diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index b72ee94f..37c048e8 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -103,6 +103,7 @@ "tests/e2e/test-node-reach-e2e.js", "tests/e2e/test-nodes-export-e2e.js", "tests/e2e/test-observer-iata-1188-e2e.js", + "tests/e2e/test-packets-compact-columns-e2e.js", "tests/e2e/test-packets-scope-column.js", "tests/e2e/test-path-inspector-coverage-e2e.js", "tests/e2e/test-rx-coverage-mobile-nav-e2e.js", diff --git a/tests/e2e/test-analytics-fluid-charts.js b/tests/e2e/test-analytics-fluid-charts.js index ba56f252..658037ce 100644 --- a/tests/e2e/test-analytics-fluid-charts.js +++ b/tests/e2e/test-analytics-fluid-charts.js @@ -80,7 +80,15 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } const tmp = path.join(os.tmpdir(), `1058-harness-${wrapperWidth}-${viewportWidth}.html`); fs.writeFileSync(tmp, harnessHTML(wrapperWidth)); - await page.goto('file://' + tmp, { waitUntil: 'domcontentloaded' }); + // Count only once style.css applies. With a and + // no scripts, DOMContentLoaded does not wait for the stylesheet; CI once + // counted 1 column at 1300px, which only unstyled (block-flow) cards + // give. Wait for 'load', then for the grid rule itself: a real CSS + // regression still fails, with a message that says so. + await page.goto('file://' + tmp, { waitUntil: 'load' }); + await page.waitForFunction(() => + getComputedStyle(document.getElementById('grid')).display === 'grid', null, { timeout: 5000 }) + .catch(() => { throw new Error('.analytics-charts never became display:grid: style.css not applied'); }); } // Helper: count distinct column-x-positions of chart cards. diff --git a/tests/e2e/test-issue-1281-location-row-e2e.js b/tests/e2e/test-issue-1281-location-row-e2e.js index ffde5cc6..420648aa 100644 --- a/tests/e2e/test-issue-1281-location-row-e2e.js +++ b/tests/e2e/test-issue-1281-location-row-e2e.js @@ -47,13 +47,27 @@ async function gotoPackets(page) { await page.waitForSelector('table tbody tr[data-hash]', { timeout: 15000 }); } +// Click the i-th packet row on its Time cell. A row's centre can land on a +// hop link (which opens the node page) or on the "+N" path pill (which opens +// the path popover), and neither selects the row; the Time cell is never +// interactive. Rows are re-queried on every call because selecting one +// re-renders the table and detaches handles taken before the click. +async function clickPacketRow(page, rowSelector, i) { + const rows = await page.$$(rowSelector); + if (!rows[i]) return false; + const cell = await rows[i].$('td.col-time'); + await (cell || rows[i]).click({ timeout: 3000 }).catch(() => null); + return true; +} + // Click rows until detail pane's Payload Type matches `wantType` (e.g. "Advert" // or any non-"Advert"). Returns true on hit, false if exhausted. async function findPacketDetailByType(page, predicate, maxRows = 40) { await page.waitForTimeout(400); - const rows = await page.$$('table tbody tr[data-hash][data-action]'); - for (let i = 0; i < Math.min(rows.length, maxRows); i++) { - await rows[i].click({ timeout: 3000 }).catch(() => null); + const ROWS = 'table tbody tr[data-hash][data-action]'; + const count = (await page.$$(ROWS)).length; + for (let i = 0; i < Math.min(count, maxRows); i++) { + if (!(await clickPacketRow(page, ROWS, i))) break; await page.waitForTimeout(350); const meta = await page.evaluate(() => { const dts = document.querySelectorAll('dl.detail-meta dt'); diff --git a/tests/e2e/test-packets-compact-columns-e2e.js b/tests/e2e/test-packets-compact-columns-e2e.js new file mode 100644 index 00000000..965d7b7b --- /dev/null +++ b/tests/e2e/test-packets-compact-columns-e2e.js @@ -0,0 +1,498 @@ +/** + * Playwright E2E — packets table column packing and the Full Names toggle. + * + * The packets table used to store every column width as a percentage of the + * table, measured once on load. Percentages scale with the screen, so on a + * wide monitor "17s ago" sat in a 180px column; and the measurement read the + * virtual-scroll spacer row (one colspan cell as wide as the table) as + * column 0, inflating the expand column too. fitColumnsToContent (app.js) + * now sizes short columns to their content in px and gives the rest to Path + * and Details. + * + * Usage: BASE_URL=http://localhost:13581 node tests/e2e/test-packets-compact-columns-e2e.js + */ +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:3000'; +const results = []; + +async function test(name, fn) { + try { + await fn(); + results.push({ name, pass: true }); + console.log(` ✅ ${name}`); + } catch (err) { + results.push({ name, pass: false, error: err.message }); + console.log(` ❌ ${name}: ${err.message}`); + } +} + +function assert(condition, msg) { + if (!condition) throw new Error(msg || 'Assertion failed'); +} + +// Upper bounds for the short columns at any desktop width. Content needs +// ~30-70px; the old percentage layout gave them 100-180px at 1920px. +const MAX_WIDTH = { + 'col-expand': 40, + 'col-time': 100, + 'col-size': 70, + 'col-hashsize': 50, + 'col-scope': 80, + 'col-rpt': 70, +}; + +async function gotoPackets(page, prefs) { + // The window goes in the URL: mobile clamps a stored window above 180min + // back to 15min, and the fixture is freshened at the start of the CI job. + await page.goto(BASE + '/#/packets?timeWindow=10080', { waitUntil: 'domcontentloaded' }); + await page.evaluate((extra) => { + localStorage.removeItem('packets-visible-cols'); + localStorage.removeItem('packets-known-cols'); + localStorage.removeItem('meshcore-pkt-col-px'); + localStorage.removeItem('meshcore-full-names'); + for (const [k, v] of Object.entries(extra || {})) localStorage.setItem(k, v); + }, prefs); + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', + { state: 'attached', timeout: 30000 }); + await settle(page); +} + +// Column layout runs synchronously after a render, and container resizes are +// coalesced to the next animation frame: two frames cover both. +function settle(page) { + return page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))); +} + +// The "+N" pills are recomputed once hop names settle (up to ~1s after a +// render). Wait for that pass to finish rather than for a fixed delay. +function pillsReady(page) { + return page.waitForFunction(() => { + const tbody = document.getElementById('pktBody'); + return tbody && !tbody._rePathOverflowObserver && + document.querySelector('#pktTable .path-overflow-pill'); + }, null, { timeout: 10000 }); +} + +async function setColumnVisible(page, key, visible) { + // A checkbox click bubbles to the document handler that closes the menu, + // so each toggle needs its own open. + await page.click('#colToggleBtn'); + const box = await page.waitForSelector(`#colToggleMenu input[data-col="${key}"]`); + if ((await box.isChecked()) !== visible) await box.click(); + await page.waitForFunction(([k, v]) => + document.getElementById('pktTable').classList.contains('hide-col-' + k) === !v, [key, visible]); + await settle(page); +} + +function layout(page) { + return page.evaluate(() => { + const t = document.getElementById('pktTable'); + const wrap = t.parentElement; + const widths = {}; + t.querySelectorAll('thead th').forEach(th => { + if (th.offsetParent === null) return; + const key = Array.from(th.classList).find(c => c.startsWith('col-')); + widths[key] = th.offsetWidth; + }); + const row = Array.from(t.querySelectorAll('tbody tr')) + .find(r => r.children.length === t.querySelectorAll('thead th').length); + const cells = row ? Array.from(row.children).filter(td => td.offsetParent !== null) : []; + const lastCell = cells[cells.length - 1]; + const wr = wrap.getBoundingClientRect(); + return { + widths, + wrapRight: wr.left + wrap.clientLeft + wrap.clientWidth, + tableRight: t.getBoundingClientRect().right, + lastCellRight: lastCell ? lastCell.getBoundingClientRect().right : null, + hScroll: wrap.scrollWidth - wrap.clientWidth, + }; + }); +} + +(async () => { + console.log(`\nPackets compact columns — ${BASE}\n`); + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] + }); + const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } }); + + await gotoPackets(page); + + await test('short columns stay short on a wide screen', async () => { + const { widths } = await layout(page); + for (const [col, max] of Object.entries(MAX_WIDTH)) { + if (widths[col] == null) continue; // hidden by a preference + assert(widths[col] <= max, `${col} is ${widths[col]}px, expected <= ${max}px (all: ${JSON.stringify(widths)})`); + } + }); + + await test('Path and Details take the freed width', async () => { + const { widths } = await layout(page); + assert(widths['col-path'] >= 400, `col-path only ${widths['col-path']}px`); + assert(widths['col-details'] >= 400, `col-details only ${widths['col-details']}px`); + }); + + await test('no blank strip after the last column, no horizontal scroll', async () => { + const l = await layout(page); + assert(l.lastCellRight != null, 'no data row to measure'); + assert(Math.abs(l.tableRight - l.lastCellRight) <= 2, + `last cell ends at ${l.lastCellRight}, table at ${l.tableRight}: a hidden column's slot is taking width`); + assert(l.hScroll <= 0, `table overflows its wrapper by ${l.hScroll}px`); + assert(Math.abs(l.wrapRight - l.tableRight) <= 2, + `table ends at ${l.tableRight}, its wrapper at ${l.wrapRight}`); + }); + + await test('hiding both Path and Details keeps the table at the wrapper width', async () => { + // Review on #2090: with no flex column the table shrank to the sum of + // the short columns (~400px in a 1900px wrapper). The gap was between + // the table and its wrapper, which the check above did not measure. + let l; + try { + await setColumnVisible(page, 'path', false); + await setColumnVisible(page, 'details', false); + l = await layout(page); + } finally { + // Restore before asserting, so a failure here does not leak two + // hidden columns into every later case. + await setColumnVisible(page, 'path', true); + await setColumnVisible(page, 'details', true); + } + assert(l.widths['col-path'] == null && l.widths['col-details'] == null, 'Path/Details still visible'); + assert(Math.abs(l.wrapRight - l.tableRight) <= 2, + `table ends at ${l.tableRight}, its wrapper at ${l.wrapRight}: ${Math.round(l.wrapRight - l.tableRight)}px empty`); + assert(l.hScroll <= 0, `table overflows its wrapper by ${l.hScroll}px`); + const back = await layout(page); + assert(back.widths['col-details'] >= 400, `Details did not take the width back: ${back.widths['col-details']}px`); + }); + + await test('opening the detail panel re-fits the table without scrolling', async () => { + await page.click('#pktTable tbody tr:not([id^=vscroll]) td.col-time'); + await page.waitForSelector('#pktRight:not(.empty)', { timeout: 10000 }) + .catch(() => { throw new Error('clicking a row did not open the detail panel'); }); + await settle(page); + const l = await layout(page); + assert(l.hScroll <= 0, `table overflows its wrapper by ${l.hScroll}px with the panel open`); + await page.keyboard.press('Escape'); + await page.waitForSelector('#pktRight.empty', { state: 'attached', timeout: 10000 }) + .catch(() => { throw new Error('Escape did not close the detail panel'); }); + await settle(page); + const after = await layout(page); + assert(after.widths['col-details'] >= 400, `Details did not grow back after closing the panel: ${after.widths['col-details']}px`); + }); + + await test('stale percentage widths from the old layout are dropped', async () => { + const stale = JSON.stringify([12.6, 0.4, 8.7, 9.1, 5, 3.4, 12.6, 6.2, 12.6, 12.6, 4, 12.8]); + await gotoPackets(page, { 'meshcore-pkt-col-widths': stale }); + const gone = await page.evaluate(() => localStorage.getItem('meshcore-pkt-col-widths') === null); + assert(gone, 'meshcore-pkt-col-widths still present'); + const { widths } = await layout(page); + assert(widths['col-time'] <= MAX_WIDTH['col-time'], `col-time ${widths['col-time']}px after a stale save`); + }); + + await test('Full Names shows observer names untruncated and lifts the chip cap', async () => { + await gotoPackets(page); + const before = await page.evaluate(() => ({ + cls: document.getElementById('pktTable').classList.contains('pkt-full-names'), + chipMax: (() => { const c = document.querySelector('#pktTable .path-hops .hop-named'); return c ? getComputedStyle(c).maxWidth : null; })(), + })); + assert(!before.cls, 'pkt-full-names set before toggling'); + if (before.chipMax) assert(before.chipMax === '120px', `default chip cap is ${before.chipMax}`); + + await page.click('#fullNamesToggle'); + await page.waitForFunction(() => document.getElementById('pktTable').classList.contains('pkt-full-names')); + await pillsReady(page); + await settle(page); + const after = await page.evaluate(() => { + const t = document.getElementById('pktTable'); + const obs = Array.from(t.querySelectorAll('td.col-observer')).map(td => td.textContent); + const chip = t.querySelector('.path-hops .hop-named'); + return { + cls: t.classList.contains('pkt-full-names'), + active: document.getElementById('fullNamesToggle').classList.contains('active'), + ellipsised: obs.filter(s => s.includes('…')).length, + chipMax: chip ? getComputedStyle(chip).maxWidth : null, + hScroll: t.parentElement.scrollWidth - t.parentElement.clientWidth, + // The virtual scroller positions rows at a fixed height: a path that + // wrapped onto a second line would break it. + tallPaths: Array.from(t.querySelectorAll('.path-hops')).filter(h => h.offsetHeight > 24).length, + // Hops past the edge are reachable through the +N pill, which must + // sit inside the clipped path rather than past its edge. + offscreenPills: Array.from(t.querySelectorAll('.path-overflow-pill')).filter(p => { + const host = p.closest('.path-hops').getBoundingClientRect(); + const r = p.getBoundingClientRect(); + return r.right > host.right + 1 || r.left < host.left; + }).length, + }; + }); + assert(after.cls && after.active, 'toggle did not switch the table to full names'); + assert(after.ellipsised === 0, `${after.ellipsised} observer cells still end in "…"`); + if (after.chipMax) assert(after.chipMax !== '120px', 'path chips still capped at 120px'); + assert(after.hScroll <= 0, `table overflows by ${after.hScroll}px in full-name mode`); + assert(after.tallPaths === 0, `${after.tallPaths} paths wrapped onto a second line`); + assert(after.offscreenPills === 0, `${after.offscreenPills} "+N" overflow pills are outside the visible path`); + }); + + await test('Full Names persists across reloads', async () => { + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', + { state: 'attached', timeout: 30000 }); + const on = await page.evaluate(() => + document.getElementById('pktTable').classList.contains('pkt-full-names') && + document.getElementById('fullNamesToggle').classList.contains('active')); + assert(on, 'full-name mode lost on reload'); + await page.click('#fullNamesToggle'); + }); + + await test('hovering the +N pill lists the full path vertically; click pins it', async () => { + await gotoPackets(page, { 'meshcore-full-names': 'true' }); + // At 1920px with full names the fixture's multi-hop paths always overflow. + await pillsReady(page).catch(() => null); + const pillSel = await page.evaluate(() => { + const pills = Array.from(document.querySelectorAll('#pktTable .path-overflow-pill')); + if (!pills.length) return null; + pills[0].closest('tr').setAttribute('data-e2e-pill-row', '1'); + return '#pktTable tr[data-e2e-pill-row] .path-overflow-pill'; + }); + // At 1920px with full names the fixture's multi-hop paths always overflow; + // no pill means the pill logic broke, not that there is nothing to test. + assert(pillSel, 'no "+N" overflow pill rendered under Full Names'); + const hopsInRow = await page.$eval(pillSel, p => + p.closest('.path-hops').querySelectorAll('.arrow').length + 1); + + await page.hover(pillSel); + await page.waitForSelector('#pathPopover', { timeout: 3000 }); + // The pill sits over the last chip: a translucent hover background let + // the chip show through it. + const hoverBg = await page.$eval(pillSel, p => getComputedStyle(p).backgroundColor); + const alpha = /rgba\([^)]*,\s*([\d.]+)\)/.exec(hoverBg); + assert(hoverBg !== 'transparent' && (!alpha || parseFloat(alpha[1]) === 1), + `hovered pill background is not opaque: ${hoverBg}`); + const pop = await page.evaluate(() => { + const el = document.getElementById('pathPopover'); + const lis = Array.from(el.querySelectorAll('li')); + return { + items: lis.length, + vertical: lis.every((li, i) => i === 0 || li.getBoundingClientRect().top > lis[i - 1].getBoundingClientRect().top), + truncated: Array.from(el.querySelectorAll('.hop-named')).filter(c => c.scrollWidth > c.clientWidth + 1).length, + title: el.querySelector('.path-popover-title').textContent, + }; + }); + assert(pop.items === hopsInRow, `popover lists ${pop.items} hops, row has ${hopsInRow}`); + assert(pop.vertical, 'hops are not stacked one per line'); + assert(pop.truncated === 0, `${pop.truncated} names truncated in the popover`); + assert(pop.title.includes(hopsInRow + ' hop'), `title "${pop.title}" should count hops, not arrows`); + + // Moving away closes an unpinned popover (after a 150ms grace period). + await page.mouse.move(5, 5); + await page.waitForSelector('#pathPopover', { state: 'detached', timeout: 3000 }) + .catch(() => { throw new Error('popover still open after the pointer left'); }); + + // Clicking pins it, and must not select the row underneath. + const panelEmptyBefore = await page.$eval('#pktRight', el => el.classList.contains('empty')); + await page.click(pillSel); + await page.waitForSelector('#pathPopover', { timeout: 3000 }); + await page.mouse.move(5, 5); + // Deliberately fixed: proving it stays open means outlasting the 150ms + // close grace an unpinned popover would get. + await page.waitForTimeout(400); + assert(await page.$('#pathPopover'), 'pinned popover closed when the pointer left'); + const panelEmptyAfter = await page.$eval('#pktRight', el => el.classList.contains('empty')); + assert(panelEmptyBefore === panelEmptyAfter, 'clicking the pill also selected the row'); + await page.keyboard.press('Escape'); + await page.waitForSelector('#pathPopover', { state: 'detached', timeout: 3000 }) + .catch(() => { throw new Error('Escape did not close the pinned popover'); }); + + // Keyboard: focusing a pill shows the popover too. Escape above also + // closes the detail panel, which re-renders the rows: query afresh. + await pillsReady(page); + await page.focus('#pktTable .path-overflow-pill'); + await page.waitForSelector('#pathPopover', { timeout: 3000 }); + await page.keyboard.press('Escape'); + + // The popover lives on : leaving the page must take it along. + await page.click('#pktTable .path-overflow-pill'); + await page.waitForSelector('#pathPopover', { timeout: 3000 }); + await page.evaluate(() => { location.hash = '#/home'; }); + await page.waitForSelector('#pathPopover', { state: 'detached', timeout: 5000 }) + .catch(() => { throw new Error('pinned path popover survived navigating away from Packets'); }); + await page.evaluate(() => { localStorage.removeItem('meshcore-full-names'); }); + }); + + await test('Full Names is carried in the URL', async () => { + await gotoPackets(page); + // Don't assume the starting state: the previous test turned Full Names on, + // and the in-page setting (not just localStorage) can reach this page's + // URL before gotoPackets reloads it. Check both directions instead. + const state = () => page.evaluate(() => ({ + on: document.getElementById('pktTable').classList.contains('pkt-full-names'), + inUrl: /[?&]fullNames=1\b/.test(location.hash), + hash: location.hash, + })); + for (let n = 0; n < 2; n++) { + const was = (await state()).on; + await page.click('#fullNamesToggle'); + await page.waitForFunction(w => document.getElementById('pktTable').classList.contains('pkt-full-names') !== w, was); + const s = await state(); + assert(s.on === s.inUrl, `Full Names is ${s.on ? 'on' : 'off'} but the URL says otherwise: ${s.hash}`); + } + // A shared link switches the mode on for a visitor whose own pref is off. + await page.evaluate(() => { localStorage.setItem('meshcore-full-names', 'false'); }); + await page.goto(BASE + '/#/packets?timeWindow=10080&fullNames=1', { waitUntil: 'domcontentloaded' }); + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', { state: 'attached', timeout: 30000 }); + const on = await page.$eval('#pktTable', t => t.classList.contains('pkt-full-names')); + assert(on, 'fullNames=1 in the URL did not turn Full Names on'); + // ...for that page only: the visitor's own saved preference is untouched. + const saved = await page.evaluate(() => localStorage.getItem('meshcore-full-names')); + assert(saved === 'false', `opening a fullNames=1 link overwrote the saved preference with ${saved}`); + await page.goto(BASE + '/#/packets?timeWindow=10080', { waitUntil: 'domcontentloaded' }); + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', { state: 'attached', timeout: 30000 }); + const offAgain = await page.$eval('#pktTable', t => !t.classList.contains('pkt-full-names')); + assert(offAgain, 'without the parameter the page did not return to the saved preference (off)'); + await page.evaluate(() => { localStorage.removeItem('meshcore-full-names'); }); + }); + + await test('Observer column shrinks back once observer names arrive late', async () => { + // Rows can render before /api/observers resolves (#1692), showing raw + // 64-char pubkeys — ~600px under Full Names. grow() alone would keep the + // column that wide after the names replace them. + await page.route('**/api/observers*', async route => { + await new Promise(r => setTimeout(r, 3000)); + await route.continue(); + }); + try { + await gotoPackets(page, { 'meshcore-full-names': 'true' }); + await page.waitForFunction(() => + !Array.from(document.querySelectorAll('#pktTable td.col-observer')) + .some(td => /[0-9A-F]{40}/.test(td.textContent)), null, { timeout: 15000 }); + await settle(page); + const { th, widest } = await page.evaluate(() => { + const cells = Array.from(document.querySelectorAll('#pktTable td.col-observer')) + .filter(td => td.offsetParent !== null); + return { + th: document.querySelector('#pktTable th.col-observer').offsetWidth, + widest: Math.max(...cells.map(td => cellContentWidth(td))), + }; + }); + assert(th <= widest + 8, `Observer column ${th}px for ${widest}px of content: stuck at the pubkey width`); + } finally { + await page.unroute('**/api/observers*'); + } + }); + + await test('dragging a handle resizes the column; double-click fits it back', async () => { + await gotoPackets(page); + const flexHandles = await page.$$eval('#pktTable th.col-path .col-resize-handle, #pktTable th.col-details .col-resize-handle', h => h.length); + assert(flexHandles === 0, 'Path/Details must not have drag handles: they share the remaining width'); + const before = await page.$eval('#pktTable th.col-hash', th => th.offsetWidth); + // Grab the handle 2px inside the header: its outer half overhangs the + // next header, which paints over it. + const box = await page.$eval('#pktTable th.col-hash', th => { + const r = th.getBoundingClientRect(); return { x: r.right - 2, y: r.top + r.height / 2 }; + }); + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + await page.mouse.move(box.x + 30, box.y, { steps: 3 }); + await page.mouse.move(box.x + 60, box.y, { steps: 3 }); + await page.mouse.up(); + await settle(page); + const dragged = await page.evaluate(() => ({ + w: document.querySelector('#pktTable th.col-hash').offsetWidth, + saved: JSON.parse(localStorage.getItem('meshcore-pkt-col-px') || '{}')['col-hash'], + })); + // The edge follows the pointer: no lag from fixed layout spreading the delta. + assert(Math.abs(dragged.w - (before + 60)) <= 5, `dragged +60px from ${before}px, column is ${dragged.w}px`); + assert(Math.abs(dragged.saved - dragged.w) <= 1, `saved ${dragged.saved}px, column ${dragged.w}px`); + const edge = await page.$eval('#pktTable th.col-hash', th => { + const r = th.getBoundingClientRect(); return { x: r.right - 2, y: r.top + r.height / 2 }; + }); + await page.mouse.dblclick(edge.x, edge.y); + await settle(page); + const reset = await page.evaluate(() => ({ + w: document.querySelector('#pktTable th.col-hash').offsetWidth, + saved: JSON.parse(localStorage.getItem('meshcore-pkt-col-px') || '{}')['col-hash'], + })); + assert(Math.abs(reset.w - before) <= 1, `double-click left the column at ${reset.w}px, expected ${before}px`); + assert(reset.saved === undefined, 'double-click did not forget the saved width'); + }); + + await test('expanding a group leaves the column widths alone', async () => { + // CI seeds one grouped transmission (fae0c9e6d357a814, 3 observations). + await page.goto(BASE + '/#/packets?hash=fae0c9e6d357a814&timeWindow=0', { waitUntil: 'networkidle' }); + // Fail, don't skip: without the seeded row this case would silently stop + // asserting (review on #2090). + await page.waitForSelector('#pktTable tr.group-header', { timeout: 10000 }) + .catch(() => { throw new Error('seeded grouped row fae0c9e6d357a814 not found: check the CI fixture seeding step'); }); + await settle(page); + const widths = () => page.$$eval('#pktTable thead th', ths => Object.fromEntries(ths + .filter(th => th.offsetParent !== null && !th.matches('.col-path, .col-details')) + .map(th => [th.className.split(' ')[0], th.offsetWidth]))); + const before = await widths(); + await page.click('#pktTable tr.group-header td.col-expand'); + await page.waitForSelector('#pktTable tr.group-child', { timeout: 10000 }); + await settle(page); + const after = await widths(); + // Child rows name other observers and may legitimately need a few px + // more; the bug was every column jumping ~14px from the row indent. + const grew = Object.keys(before).filter(k => k !== 'col-time' && after[k] - before[k] > 8); + assert(grew.length === 0, `columns widened on expand: ${grew.map(k => `${k} ${before[k]}→${after[k]}`).join(', ')}`); + await page.keyboard.press('Escape'); + }); + + await test('1100px with the detail panel open: the table still fits', async () => { + // All columns are visible above 1024px, and the panel leaves ~650px. + await page.setViewportSize({ width: 1100, height: 900 }); + await gotoPackets(page); + await page.click('#pktTable tbody tr:not([id^=vscroll]) td.col-time'); + await page.waitForSelector('#pktRight:not(.empty)', { timeout: 10000 }); + await settle(page); + const { hScroll } = await layout(page); + assert(hScroll <= 0, `table overflows its wrapper by ${hScroll}px`); + await page.keyboard.press('Escape'); + }); + + await test('revealing and re-hiding responsive columns re-fits the table', async () => { + // At <=1024px TableResponsive hides Hash, Observer and Rpt behind "+N hidden". + await page.setViewportSize({ width: 1000, height: 900 }); + await gotoPackets(page); + const pill = await page.$('#pktTable .col-hidden-pill:not(.col-rehide-pill)'); + assert(pill, 'no "+N hidden" pill at 1000px'); + const check = async (label) => { + const l = await layout(page); + assert(l.hScroll <= 0, `${label}: table overflows by ${l.hScroll}px`); + assert(Math.abs(l.tableRight - l.lastCellRight) <= 2, + `${label}: last cell ends at ${l.lastCellRight}, table at ${l.tableRight} (blank strip)`); + }; + await pill.click(); + await page.waitForFunction(() => document.querySelector('#pktTable th.col-observer').offsetParent !== null, null, { timeout: 5000 }) + .catch(() => { throw new Error('Observer not revealed'); }); + await settle(page); + await check('after reveal'); + await page.click('#pktTable .col-rehide-pill'); + await page.waitForFunction(() => document.querySelector('#pktTable th.col-observer').offsetParent === null, null, { timeout: 5000 }); + await settle(page); + await check('after re-hide'); + }); + + await test('mobile (375px): table fits without horizontal scroll', async () => { + await page.setViewportSize({ width: 375, height: 800 }); + await gotoPackets(page); + const { hScroll } = await layout(page); + assert(hScroll <= 0, `table overflows by ${hScroll}px at 375px`); + }); + + await browser.close(); + + const passed = results.filter(r => r.pass).length; + const failed = results.length - passed; + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +})().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/tests/unit/test-issue-1189-composed-cell.js b/tests/unit/test-issue-1189-composed-cell.js index 9970bef3..19331209 100644 --- a/tests/unit/test-issue-1189-composed-cell.js +++ b/tests/unit/test-issue-1189-composed-cell.js @@ -61,6 +61,7 @@ if (m) { isSingle: false, truncate(s, n) { return String(s || '').slice(0, n); }, obsNameOnly(_id) { return 'NameA'; }, + obsCellName(_id, n) { return 'NameA'.slice(0, n); }, obsIataBadge(_p) { return ''; }, groupedObserverIataBadgesHtml(_p) { return 'SJCSFO +1'; diff --git a/tests/unit/test-issue-2012-clear-filters-selection.js b/tests/unit/test-issue-2012-clear-filters-selection.js index 8d12db8d..f4b21e6b 100644 --- a/tests/unit/test-issue-2012-clear-filters-selection.js +++ b/tests/unit/test-issue-2012-clear-filters-selection.js @@ -107,13 +107,13 @@ function setup(hash = '#/packets', initialFilters = {}) { 'filters', 'observers', 'observerMap', 'SHORT_BY_ID', 'escapeHtml', 'document', 'localStorage', 'RegionFilter', 'renderTableRows', 'loadPackets', '_rebuildObserverMenu', '_observerFilterSet', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', - 'location', 'history', 'window', '_packetSortColumn', '_packetSortDirection', + 'location', 'history', 'window', '_packetSortColumn', '_packetSortDirection', 'showFullNames', hashParamsSrc + '\n' + urlSrc + '\n' + multiSelectSrc + '\n' + clearSrc + '\n' + 'updatePacketsUrl(); return { updatePacketsUrl, buildPacketsQuery };' ); const actions = run(filters, observers, observerMap, SHORT_BY_ID, escapeHtml, document, localStorage, RegionFilter, noop, noop, null, null, 15, 15, - location, history, {}, null, null); + location, history, {}, null, null, false); // Observer rows live in #observerMenu today and in #observerList after // #1884; the change listener stays on #observerMenu in both layouts. @@ -274,6 +274,7 @@ test('#2091: init restores obs from the hash after router strips the query', () const readInitialObservation = new Function('location', 'routeParam', 'let directObsId = "stale", directPacketId = null, directPacketHash = null; ' + 'let savedTimeWindowMin = 15, _pendingUrlRegion = null; const filters = {}, window = {}; ' + + 'let showFullNames = false; const localStorage = { getItem: () => null, setItem() {} }; ' + hashParamsSrc + '\n' + initParamsSrc + '\nreturn directObsId;'); for (const route of ['aabbccddeeff0011', 'id/42']) { assert.strictEqual(readInitialObservation({ hash: '#/packets/' + route + '?obs=123' }, route), '123'); diff --git a/tests/unit/test-packets.js b/tests/unit/test-packets.js index 39072f5c..356d7ba5 100644 --- a/tests/unit/test-packets.js +++ b/tests/unit/test-packets.js @@ -959,7 +959,26 @@ console.log('\n=== packets.js: buildGroupRowHtml ==='); }; const result = api.buildGroupRowHtml(p); assert(result.includes('group-header')); - assert(result.includes('href="/icons/phosphor-sprite.svg#ph-caret-up"')); // collapsed arrow + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-caret-right"')); // collapsed arrow + }); + + test('buildGroupRowHtml draws exactly one chevron in the expand cell', () => { + const p = { + hash: 'one', count: 3, latest: '2024-01-01T00:00:00Z', + observer_id: null, raw_hex: 'aabbcc', payload_type: 0, + route_type: 0, decoded_json: '{}', path_json: '[]', + observation_count: 3, observer_count: 2 + }; + const result = api.buildGroupRowHtml(p); + const cell = result.match(/`'; const fn = new Function( - 'p','escapeHtml','truncate','obsNameOnly','obsIataBadge', + 'p','obsCellName','obsIataBadge', 'return ' + tpl + ';' ); - // Use short payloads — truncate(_, 16) would lop off the trailing single - // quote of a longer string. Both must survive in the first 16 chars. - const html = fn( - { observer_id: 'obs-1' }, escapeHtml, - (s, n) => String(s).slice(0, n), - () => "' "'
RegionTimeHashSize HB
${isSingle ? '' : (isExpanded ? '' : '')}${isSingle ? '' : (isExpanded ? '' : '')} ${groupRegion ? `${groupRegion}` : '—'} ${renderTimestampCell(p.latest)} ${truncate(p.hash || '—', 8)}${groupHashBytes} ${p.payload_type != null ? `${groupTypeName}${transportBadge(p.route_type)}` : '—'} ${scopeCellHtml(p.scope_name)}${isSingle ? escapeHtml(truncate(obsNameOnly(headerObserverId), 16)) + obsIataBadge(p) : escapeHtml(truncate(obsNameOnly(headerObserverId), 10)) + groupedObserverIataBadgesHtml(p)}${isSingle ? obsCellName(headerObserverId, 16) + obsIataBadge(p) : obsCellName(headerObserverId, 10) + groupedObserverIataBadgesHtml(p)} ${groupPathStr} ${p.observation_count > 1 ? ' ' + p.observation_count + '' : (isSingle ? '' : p.count)} ${getDetailPreview(getParsedDecoded(p))}${childHashBytes} ${typeName}${transportBadge(c.route_type)} ${scopeCellHtml(c.scope_name)}${escapeHtml(truncate(obsNameOnly(c.observer_id), 16))}${obsIataBadge(c)}${obsCellName(c.observer_id, 16)}${obsIataBadge(c)} ${childPathStr} ${getDetailPreview(getParsedDecoded(c))}${hashBytes} ${typeName}${transportBadge(p.route_type)} ${scopeCellHtml(p.scope_name)}${escapeHtml(truncate(obsNameOnly(p.observer_id), 16))}${obsIataBadge(p)}${obsCellName(p.observer_id, 16)}${obsIataBadge(p)} ${pathStr} ${detail}]*>([\s\S]*?)<\/td>/)[1]; + assert.strictEqual((cell.match(/ph-caret-/g) || []).length, 1); + assert(!/[▶▼▲▸]/.test(cell), 'no text glyph alongside the SVG caret'); + }); + + test('style.css adds no ::before glyph to group-header rows', () => { + const css = fs.readFileSync('public/style.css', 'utf8'); + assert(!/\.group-header[^{]*::before/.test(css), + 'a ::before on .group-header draws a second arrow next to the SVG caret'); }); test('buildGroupRowHtml shows observation count badge', () => { @@ -1242,6 +1261,174 @@ console.log('\n=== packets.js: scroll position preserved across renderTableRows }); } +console.log('\n=== app.js: distributeColumnWidths (packets column packing) ==='); +{ + const ctx = loadPacketsSandbox(); + const distribute = ctx.distributeColumnWidths; + const col = (w, floor) => ({ w, floor: floor == null ? Math.min(w, 64) : floor }); + const sum = a => a.reduce((s, w) => s + w, 0); + + test('roomy container: fixed columns keep their content width, flex columns take the rest', () => { + // time 65, size 44, hb 29 on a 2000px container: nothing inflates. + const d = distribute([col(65), col(44), col(29)], 2, 2000, 120); + assert.deepStrictEqual(Array.from(d.fixed), [65, 44, 29]); + assert.strictEqual(sum(d.flex), 2000 - 138); + assert.strictEqual(d.total, 2000); + }); + + test('rounding remainder goes to the last flex column', () => { + const d = distribute([col(10, 10)], 2, 111, 20); + assert.deepStrictEqual(Array.from(d.flex), [50, 51]); + assert.strictEqual(d.total, 111); + }); + + test('tight container: widest fixed columns give way first so flex keeps its minimum', () => { + // observer 178 and type 115 absorb the squeeze; the short columns do not move. + const d = distribute([col(65), col(44), col(115), col(178)], 2, 600, 120); + assert.deepStrictEqual(Array.from(d.flex), [120, 120]); + assert.strictEqual(d.fixed[0], 65); + assert.strictEqual(d.fixed[1], 44); + assert(d.fixed[3] <= 178 && d.fixed[3] >= 64, 'observer capped, not below its floor'); + assert(d.fixed[2] <= 115, 'type capped at the same level'); + assert(d.total <= 600, 'fits: total=' + d.total); + }); + + test('floors are never crossed: overflow instead (caller scrolls)', () => { + const d = distribute([col(200, 150), col(200, 150)], 1, 300, 120); + assert.deepStrictEqual(Array.from(d.fixed), [150, 150]); + assert.deepStrictEqual(Array.from(d.flex), [120]); + assert.strictEqual(d.total, 420); + }); + + test('a dragged column (floor = width) keeps its width under pressure', () => { + const d = distribute([col(300, 300), col(200)], 1, 500, 120); + assert.strictEqual(d.fixed[0], 300); + assert(d.fixed[1] < 200); + }); + + test('floors leave less than flexMin: flex columns shrink to the hard minimum before overflowing', () => { + // ~1100px viewport with the detail panel open: floors 467px, 650px available. + const d = distribute([col(467, 467)], 2, 650, 120, 60); + assert.deepStrictEqual(Array.from(d.flex), [91, 92]); + assert.strictEqual(d.total, 650); + }); + + test('hard minimum still overflows when even it does not fit', () => { + const d = distribute([col(600, 600)], 2, 650, 120, 60); + assert.deepStrictEqual(Array.from(d.flex), [60, 60]); + assert.strictEqual(d.total, 720); + }); + + test('no flex columns (Path and Details hidden): the last column takes the slack', () => { + // A 109px table in a 2000px wrapper was the review finding on #2090. + const d = distribute([col(65), col(44)], 0, 2000, 120); + assert.deepStrictEqual(Array.from(d.flex), []); + assert.deepStrictEqual(Array.from(d.fixed), [65, 1935]); + assert.strictEqual(d.total, 2000); + }); + + test('no flex columns and no room: nothing is added', () => { + const d = distribute([col(300, 300), col(300, 300)], 0, 500, 120); + assert.deepStrictEqual(Array.from(d.fixed), [300, 300]); + assert.strictEqual(d.total, 600); + }); +} + +console.log('\n=== app.js: measurableRows (shared with makeColumnsResizable) ==='); +{ + const ctx = loadPacketsSandbox(); + const cell = (colSpan) => ({ colSpan: colSpan || 1 }); + const row = (...cells) => ({ children: cells, id: '' }); + const tableWith = rows => ({ querySelector: sel => (sel === 'tbody' ? { children: rows } : null) }); + + test('skips virtual-scroll spacer and loading rows (one colspan cell)', () => { + const spacer = row(cell(12)); + const data = row(cell(), cell(), cell()); + const rows = ctx.measurableRows(tableWith([spacer, data, spacer])); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0], data); + }); + + test('keeps rows whose cells are all single-column, whatever their count', () => { + // Other tables (nodes, observers, analytics) can have rows shorter than + // the header; those were measured before and still are. + const short = row(cell(), cell()); + const full = row(cell(), cell(), cell()); + assert.strictEqual(ctx.measurableRows(tableWith([short, full])).length, 2); + }); + + test('drops any row that contains a spanning cell, not only full-width ones', () => { + const partial = row(cell(), cell(2)); + assert.strictEqual(ctx.measurableRows(tableWith([partial])).length, 0); + }); + + test('limits to 30 rows by default, as makeColumnsResizable always sampled', () => { + const many = Array.from({ length: 50 }, () => row(cell(), cell())); + assert.strictEqual(ctx.measurableRows(tableWith(many)).length, 30); + assert.strictEqual(ctx.measurableRows(tableWith(many), Infinity).length, 50); + }); + + test('no tbody: no rows', () => { + assert.strictEqual(ctx.measurableRows({ querySelector: () => null }).length, 0); + }); + + test('style.css clears cell min-width on #pktTable (JS widths are the only input)', () => { + const css = fs.readFileSync('public/style.css', 'utf8'); + assert(/#pktTable th, #pktTable td \{ min-width: 0; \}/.test(css)); + }); +} + +console.log('\n=== packets.js: Full Names toggle (observer column) ==='); +{ + const ctx = loadPacketsSandbox(); + const api = ctx._packetsTestAPI; + // Not in observerMap, so obsNameOnly falls back to the id itself. + const longName = 'DntnMarina Rptr mrymesh.net'; + const p = { + id: 9, hash: 'fn', timestamp: '', observer_id: longName, + raw_hex: 'aabb', payload_type: 0, route_type: 0, + decoded_json: '{}', path_json: '[]' + }; + const observerCell = html => html.match(/]*>([\s\S]*?)<\/td>/)[1]; + + test('observer name is shortened by default', () => { + api._setFullNames(false); + const cell = observerCell(api.buildFlatRowHtml(p)); + assert(!cell.includes(longName), 'expected a truncated name, got ' + cell); + }); + + test('Full Names shows the observer name untruncated', () => { + api._setFullNames(true); + const cell = observerCell(api.buildFlatRowHtml(p)); + assert(cell.includes(longName), 'expected the full name, got ' + cell); + api._setFullNames(false); + }); + + test('Full Names also untruncates grouped rows (10-char cut)', () => { + const g = { + hash: 'fng', count: 3, latest: '2024-01-01T00:00:00Z', + observer_id: longName, raw_hex: 'aabb', payload_type: 0, + route_type: 0, decoded_json: '{}', path_json: '[]', + observation_count: 3, observer_count: 2 + }; + api._setFullNames(false); + assert(!observerCell(api.buildGroupRowHtml(g)).includes(longName)); + api._setFullNames(true); + assert(observerCell(api.buildGroupRowHtml(g)).includes(longName)); + api._setFullNames(false); + }); + + test('style.css lifts the path chip cap only under .pkt-full-names', () => { + const css = fs.readFileSync('public/style.css', 'utf8'); + assert(/\.path-hops \.hop-named \{[^}]*max-width: 120px/.test(css), 'default 120px cap kept'); + assert(/#pktTable\.pkt-full-names \.path-hops \.hop-named \{[^}]*max-width: none/.test(css), + "no cap under full names: one long name must overflow so the +N pill appears"); + // Wrapping would make rows taller than the virtual scroller's fixed row + // height; overflowing hops go behind the +N pill instead. + assert(!/pkt-full-names[^{]*\{[^}]*flex-wrap: wrap/.test(css), 'full names must not wrap the path'); + }); +} + // ===== SUMMARY ===== console.log(`\n${'='.repeat(40)}`); console.log(`packets.js tests: ${passed} passed, ${failed} failed`); diff --git a/tests/unit/test-xss-escape-sinks.js b/tests/unit/test-xss-escape-sinks.js index 984edbcf..350927cc 100644 --- a/tests/unit/test-xss-escape-sinks.js +++ b/tests/unit/test-xss-escape-sinks.js @@ -193,11 +193,23 @@ test('observers.js renderRow: observer name cell escapes o.name', () => { assertNoXss(html, 'observers.js obs name cell'); }); +// The observer name reaches the packets table through obsCellName(), which +// shortens it (unless the Full Names toggle is on) and escapes it. Build the +// REAL helper from packets.js source so a regression in either the template +// or the helper turns these tests red. +function loadObsCellName(nameFn, fullNames) { + const src = fs.readFileSync('public/packets.js', 'utf8'); + const m = src.match(/function obsCellName\(id, maxLen\) \{[\s\S]*?\n \}/); + assert.ok(m, 'packets.js obsCellName helper not found'); + return new Function('escapeHtml', 'truncate', 'obsNameOnly', 'showFullNames', + m[0] + '; return obsCellName;')( + escapeHtml, (s, n) => String(s).slice(0, n), nameFn, fullNames); +} + // --- 5. public/packets.js observer cell (grouped header — isSingle path) -- test('packets.js: grouped observer cell escapes observer name', () => { - // Capture the isSingle ternary on the grouped header row. - // Master form: `${isSingle ? truncate(obsNameOnly(headerObserverId), 16) + obsIataBadge(p) : ...}` - // Fixed form: `${isSingle ? escapeHtml(truncate(obsNameOnly(headerObserverId), 16)) + obsIataBadge(p) : ...}` + // Capture the isSingle ternary on the grouped header row: + // `${isSingle ? obsCellName(headerObserverId, 16) + obsIataBadge(p) : ...}` const src = fs.readFileSync('public/packets.js', 'utf8'); const m = src.match( /()/ @@ -205,51 +217,56 @@ test('packets.js: grouped observer cell escapes observer name', () => { assert.ok(m, 'packets.js grouped observer cell template not found'); const tpl = '`' + m[1] + '`'; const fn = new Function( - 'isSingle','headerObserverId','escapeHtml','truncate','obsNameOnly', + 'isSingle','headerObserverId','escapeHtml','obsCellName','obsNameOnly', 'obsIataBadge','groupedObserverIataBadgesHtml','p', 'return ' + tpl + ';' ); - const html = fn( - true, 'obs-1', escapeHtml, - (s, n) => String(s).slice(0, n), - () => TAG_PAYLOAD + ATTR_PAYLOAD, - () => '', () => '', {} - ); - assertNoXss(html, 'packets.js grouped observer cell'); + const name = () => TAG_PAYLOAD + ATTR_PAYLOAD; + // Full Names on: the whole payload reaches the cell. + for (const isSingle of [true, false]) { + const html = fn(isSingle, 'obs-1', escapeHtml, loadObsCellName(name, true), name, + () => '', () => '', {}); + assertNoXss(html, 'packets.js grouped observer cell (full names, isSingle=' + isSingle + ')'); + } + // Shortened: a 10-char payload survives the 10/16-char cut intact. + const short = () => "' '', () => '', {}); + assert.ok(!/ { - // Capture the flat observer cell — the OUTER form, with or without - // escapeHtml: `${truncate(obsNameOnly(p.observer_id), 16)}${obsIataBadge(p)}` - // or `${escapeHtml(truncate(obsNameOnly(p.observer_id), 16))}${obsIataBadge(p)}`. + // Capture the flat observer cell: `${obsCellName(p.observer_id, 16)}${obsIataBadge(p)}`. const src = fs.readFileSync('public/packets.js', 'utf8'); - // Find the FLAT row (the third occurrence with p.observer_id, not c.observer_id). - const re = /\$\{(?:escapeHtml\()?truncate\(obsNameOnly\(p\.observer_id\),\s*16\)\)?\}\$\{obsIataBadge\(p\)\}/; + const re = /\$\{obsCellName\(p\.observer_id,\s*16\)\}\$\{obsIataBadge\(p\)\}/; const m = src.match(re); assert.ok(m, 'packets.js flat observer cell template not found'); const tpl = '`' + m[0] + '