Skip to content

fix(packets): pack short columns, one expand arrow, Full Names toggle - #2090

Open
sylr wants to merge 5 commits into
Kpa-clawbot:masterfrom
sylr:fix/packets-compact-columns
Open

sylr wants to merge 5 commits into
Kpa-clawbot:masterfrom
sylr:fix/packets-compact-columns

Conversation

@sylr

@sylr sylr commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

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

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.

@efiten

efiten commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Reviewed against upstream/master at 9eb30988. The deploy.yml change is exactly two lines adding one E2E suite to the existing alphabetical list, with the same env and the same BASE_URL=http://localhost:13581 as its neighbours, and the suite is registered in scripts/non-unit-tests.json to match. No trigger, secret, image or deploy-step change. That was the file I read hardest and it is clean.

distributeColumnWidths is a pure function with exact-value tests, and I recomputed three of them by hand. Incremental rendering is preserved: the scroll path still inserts only new rows and grow(inserted) measures only those. Theming is clean, --surface-1 and --row-hover are both already mapped in customize.js. The XSS change is a genuine improvement: test-xss-escape-sinks.js now extracts the real helper and fails loudly if the regex misses it.

One thing should be fixed before this merges.

Hiding the Details column pins the table to the width of the short columns

public/app.js:2295 is new:

table.style.width = d.total + 'px';

Master used table.style.width = '100%', so the table always filled the wrapper. d.total is fixedSum + sum(flex), and when no flex column is visible there is nothing to absorb the slack. Your own unit test states the outcome:

test('no flex columns: fixed widths only', () => {
  const d = distribute([col(65), col(44)], 0, 2000, 120);
  assert.deepStrictEqual(Array.from(d.flex), []);
  assert.strictEqual(d.total, 109);
});

2000px available, 109px table.

To reproduce: /packets, Columns ▾, untick Details (and Path as well above 768px, where it is still visible). Both are in COL_DEFS at packets.js:2184 and :2186 and are hidden with display: none, so the <th>'s offsetParent is null and layout() correctly treats them as absent, leaving flexThs empty. At 1920px that is roughly 400px of table against 1500px of empty wrapper, which is the shape of the problem this PR set out to remove.

The new E2E does not catch it: it asserts Math.abs(tableRight - lastCellRight) <= 2, and that still holds. The gap is between the table and the wrapper, not inside the table.

Fix shape: when flexThs.length === 0, either set table.style.width = '100%' or give the slack to the last visible fixed column. Either needs an E2E case that unticks Details and asserts the table reaches the wrapper's right edge.

Three things worth a decision

The shared helper changes behaviour for three other pages. app.js:1990 replaces the measurement sample with measurableRows(table) (:2113), which switches from tbody.querySelectorAll('tr') to tbody.children and drops rows containing a colSpan > 1 cell. makeColumnsResizable is still the sizer for #nodesTable (nodes.js:1595), #obsTable (observers.js:439) and every analytics table (analytics.js:336). The change is in the right direction, but no test in this PR covers it and the browser validation section names only the packets page. I did confirm measured widths are not persisted, so a measurement taken during a "Loading…" colspan row cannot reach localStorage; the exposure is per-load column widths on three other pages. Either add a measurement test for the colspan skip plus a browser check on those pages, or split that hunk into its own PR so this one stays confined to the packets table.

Stale min-widths the layout now depends on being ignored. style.css:1321 gives .col-time min-width: clamp(72px, 8vw, 108px), :1593 gives col-path min-width: 140px, :2176 gives .col-observer min-width: 70px. Your table claims Time at 67px and the E2E bounds it at <= 100px, and flexHardMin: 60 for Path is below 140px. Both can only hold if cell min-width is ignored for column sizing under table-layout: fixed, which Chromium evidently does. The suite is Chromium-only. measure() already reads getComputedStyle(th).maxWidth at app.js:2266-2267 but never minWidth, which is what makes this look unintentional rather than deliberate. Either delete the now-dead min-widths as part of this change, or clamp measure() by minWidth the way it already clamps by maxWidth.

fullNames=0 is never written to the URL. packets.js:781 emits the parameter only when the mode is on, although the reader at :1203-1206 handles '0'. So a link captured with the toggle off does not turn it off for a visitor whose stored preference is on. The E2E case asserts s.on === s.inUrl in both directions, which holds because "off" means "absent", so it does not catch the asymmetry. Separately, reading the parameter calls localStorage.setItem('meshcore-full-names', ...) at :1206, so opening somebody else's link permanently replaces that visitor's own preference. localStorage as the store with a URL override is the right call for a view mode; the write-back is the part I would question.

Two collisions to be aware of, now that #2080 and #2093 have merged

Both landed on master after this branch was opened.

  • fix(packets): stabilize fixture tests and preserve mobile empty states #2080's only product change was a hunk at packets.js:37 inside colIndexCells, adjacent to where this PR inserts notifyIfChanged at :33-38. A textual conflict is near certain. Both attack the same class of bug, colspan spacer rows polluting per-column work.
  • fix(packets): preserve observation selection in detail URLs #2093 changed buildPacketsQuery/updatePacketsUrl and the _initUrlParams block, which this PR also edits, and both edit tests/unit/test-issue-2012-clear-filters-selection.js. That test drives new Function(...names, src) and a positional run(...). This PR appends 'showFullNames' to the name list and false to the arguments. A rebase that resolves cleanly in text but loses one name or one argument shifts every later argument by one and the test keeps running with the wrong values bound. Worth counting both lists by hand after the rebase.

Minor, no action needed

  • test-packets-compact-columns-e2e.js:357-361 prints (no grouped row in this fixture, skipped) and returns, which records a pass. If the fixture seeding stops producing a grouped row, that case silently stops asserting. Your own comment in the pill case ("no pill means the pill logic broke") is the pattern that is missing here.
  • The suite carries 19 fixed waitForTimeout calls, a pointer drag asserted to ±3px and a dblclick on a 9px handle, and it goes straight into the deploy gate. Given how often the gate is the flaky part rather than the PR, that is a cost worth being deliberate about.
  • test-packets-compact-columns-e2e.js:305-313 compares cellContentWidth(td) against th.offsetWidth, where cellContentWidth is the production function from app.js:2129. A bug inside it moves both sides together. It still catches the regression it was written for.
  • app.js:2379-2384 calls layout() raw on every ResizeObserver notification, and layout() writes th.style.width then reads table.offsetWidth, a forced synchronous layout, once per frame while dragging the detail divider. The clientWidth === lastWrapW guard prevents an observer loop but does not coalesce.
  • fitColumnsToContent claims the same table.dataset.resizable flag that makeColumnsResizable uses as its already-wired guard (app.js:1962 vs :2328). Latent today, silent if it ever triggers.
  • .eslintrc.json:246 breaks the file's alphabetical order.

The packets table stored every column width as a percentage of the
table, measured once on load. Percentages scale with the screen, so on
a 2000px display "17s ago" sat in a 180px column. The measurement also
read the virtual-scroll spacer row (one colspan cell as wide as the
table) as column 0, which is why the expand column came out 160-950px
wide. Grouped rows drew two arrows: a CSS ::before glyph next to the
SVG caret, which itself pointed up when collapsed.

Columns: fitColumnsToContent (app.js) sizes Time, Hash, Size, HB,
Type, Scope, Observer and Rpt to their content in px; Path and Details
split the rest. Every visible column gets an explicit width and the
table their sum, because a colspan spacer keeps a slot for each hidden
column that would otherwise take a share of the slack as a blank strip.
When space is short the widest fixed columns shrink first, then the
flex columns down to 60px, then the wrapper scrolls. The layout re-runs
on container resize (detail panel), on TableResponsive hiding or
revealing columns (new table-columns-changed event), after sorting and
column toggles; after each render, grow() widens columns for the rows
just inserted and never narrows them, so nothing jitters on scroll.
Drag handles store px; double-click resets. makeColumnsResizable
(nodes, observers, analytics) now skips colspan rows when measuring.

Full Names: a toolbar toggle (localStorage, and fullNames=1 in the
URL) shows observer and hop names untruncated. Paths stay on one line;
hops past the edge go behind the "+N" pill. The pill was appended after
the overflowing chips and so never showed; it is now sticky at the
right edge, counts only hop chips, and hovering or focusing it shows the
full path one hop per line (click, Enter or Space pins it). Rows drawn
before /api/observers resolves show raw pubkeys, so loadObservers now
re-renders and re-measures instead of leaving Observer at id width.

Child rows indent only the Time cell: indenting every cell misaligned
them with the headers and widened every column on expand.

Perf: grow() is ~0.9ms per full render at 63 rows; a scroll step
measures only its inserted rows (~73 rect reads, was 5,472).

Constraint: virtual scroll positions rows at one fixed height, so Full Names cannot wrap paths
Rejected: Wrap long paths onto extra lines under Full Names | breaks VSCROLL_ROW_HEIGHT row positioning
Rejected: Keep percentage widths, fix only the spacer measurement | still inflates short columns on wide screens
Rejected: Leave Path and Details width:auto | the phantom spacer slots take a share and render a blank strip
Directive: fitColumnsToContent sets table.style.width; a table using it must not rely on width:100%
Directive: rows must keep a constant height; anything that wraps in #pktTable breaks virtual scroll offsets
Confidence: high
Scope-risk: moderate
Not-tested: very large expanded groups (hundreds of child rows on screen at once)
packets.js calls fitColumnsToContent, defined in app.js. Every such
cross-file global is listed in .eslintrc.json; this one was missing, so
eslint failed the Go Build & Test job with no-undef.

Confidence: high
Scope-risk: narrow
…oth ways

Kpa-clawbot#1281 clicked each packet row at its centre. With the short columns
packed, an ADVERT row's centre is a hop link in the Path column, which
opens the node page instead of selecting the row, so no ADVERT detail
with a Location row was ever found. It now clicks the Time cell, which
holds nothing interactive, and re-queries the row before each click:
selecting a row re-renders the table, so the handles it collected up
front were detached after the first click and it only ever tried row 0.

The compact-columns URL check assumed Full Names started off. The
previous case leaves it on in the page's own state, and returning to
Packets can write fullNames=1 back into the hash before the reload, so
the click turned it off. It now asserts that the URL matches the state
after each of two clicks.

Rejected: Also switch test-e2e-playwright.js's row loops to the helper | they then check real rows and hit a hex-strip/hop-count mismatch that exists on master, outside this change
Confidence: high
Scope-risk: narrow
Hiding both Path and Details left no flex column, so the table shrank
to the sum of the short columns (~400px in a 1900px wrapper).
distributeColumnWidths now gives the spare width to the last fixed
column. That column is marked fit-last and loses its drag handle: it
has nothing to resize against, and the handle's 4px overhang
(right: -4px) gave the wrapper a scrollbar.

Review decisions:
- measurableRows, shared with makeColumnsResizable, gets unit tests.
  Of 20 tables on the nodes, observers and analytics pages, 18 measure
  identically to master; the Scopes empty state (one colspan row) no
  longer sizes the Region column to 915px; Route Patterns varies
  between runs on master too.
- #pktTable clears cell min-width: the shared .col-time/.col-path/
  .col-observer/.col-scope minimums are for auto-layout tables, and
  only Chromium ignores them under table-layout: fixed.
- fullNames in the URL applies to that page only and no longer
  overwrites the visitor's saved preference; without the parameter
  each visit reads the saved preference again.

Minor: container resizes are coalesced to one layout per frame; the
sizer uses its own data-fit-columns flag instead of makeColumnsResizable's
data-resizable; .eslintrc.json globals back in order.

E2E: new case hides Path and Details and asserts the table reaches the
wrapper's right edge; the grouped-row case fails instead of skipping;
fixed sleeps replaced by condition waits except one that must outlast
the popover's 150ms close grace; drag tolerance 3px -> 5px.

Rejected: table.style.width = '100%' when no flex column is visible | phantom colspan slots take the slack and reappear as a blank strip
Rejected: clamp measure() by the cells' CSS min-width | brings Time back to 108px, the waste this PR removes
Rejected: write fullNames=0 into every URL | clutters every link for a default-off mode
Confidence: high
Scope-risk: narrow
Not-tested: non-Chromium engines (CI runs Chromium only)
@sylr
sylr force-pushed the fix/packets-compact-columns branch from 857ca67 to 98c2f7b Compare September 30, 2026 15:19
@sylr

sylr commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Review feedback addressed (commit 98c2f7ba), on top of a rebase onto master at 093e320c.

Rebase

Both conflicts you predicted happened, and both were independent additions:

Blocking

  1. Hiding Path and Details no longer shrinks the table. distributeColumnWidths gives the spare width to the last fixed column when no flex column is visible. That column is marked fit-last and loses its drag handle: there is nothing to resize it against, and the handle's right: -4px overhang was giving the wrapper a 4px scrollbar once Rpt became the last column. New unit cases (no flex columns ... takes the slack, no flex columns and no room) and a new E2E case that unticks both through the Columns menu and asserts the table's right edge meets the wrapper's right edge, which the old check did not measure. The existing blank-strip case asserts the wrapper edge too now.

Decisions

  1. measurableRows on the other pages. Kept in this PR, with five unit cases: spacer and loading rows skipped, short rows kept, any spanning cell dropped, the 30-row default, no tbody. Browser comparison against master at 1440px with fresh storage, across 20 tables on #/nodes, #/observers and 12 analytics tabs: 18 identical. The Scopes empty state (a single colspan="3" "No scoped messages" row) no longer sizes Region to 915px, which is the same bug this PR fixes on packets. Route Patterns differs by a few px, but master against itself differs by the same amount between runs, so that is measurement noise, not this change.
  2. Stale min-widths. #pktTable th, #pktTable td { min-width: 0; } with a comment: the shared .col-time/.col-path/.col-observer/.col-scope minimums are for auto-layout tables, and only Chromium ignores them under fixed layout. I did not clamp measure() by minWidth, because that puts Time back at 108px on wide screens.
  3. fullNames URL parameter. Reading it no longer calls localStorage.setItem: the link applies to that page only, and the visitor's saved preference is untouched. Without the parameter, each visit reads the saved preference again (this also removes the in-memory carry-over that made the URL case racy). I kept omitting fullNames=0: it is a default-off mode, and writing it into every URL is clutter. The E2E case now also asserts the saved preference survives opening a fullNames=1 link, and that the page returns to it without the parameter.

Minor

  1. The grouped-row case fails instead of returning, with a message pointing at the CI seeding step.
  2. 18 of the 19 fixed waitForTimeout calls are now condition waits: pill re-finalize done (#pktBody._rePathOverflowObserver cleared), popover detached, toggle class flipped, two animation frames for layout. The one left must outlast the popover's 150ms close grace to prove a pinned popover stays open, and it says so. Drag tolerance ±3 → ±5px.
  3. cellContentWidth in the late-observer case: left as is. It still catches the regression it was written for.
  4. The ResizeObserver coalesces to one layout() per animation frame; destroy() cancels a pending frame.
  5. fitColumnsToContent uses its own data-fit-columns flag.
  6. .eslintrc.json: fitColumnsToContent back in alphabetical order.

Verification

  • Unit: 182/183 locally. test-issue-1956-release-routing.js cannot create a temp directory in my sandbox and is unrelated.
  • test-packets-compact-columns-e2e.js: 16/16, three runs in a row and three in parallel.
  • All 116 suites in the fail-fast E2E step pass against this branch, on a fixture prepared as CI does (freshen, grouped-row seed, migrate, path-inspector.sql).
  • eslint: 0 errors.

@sylr

sylr commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

CI on 98c2f7ba failed in test-analytics-fluid-charts.js, a suite this PR does not touch: "viewport 1440 / wrapper 1300px → side-by-side (≥2 cols): expected ≥2 columns at wrapper 1300px; got 1". I cannot re-run jobs here, so 4f0806d5 hardens that harness instead.

At 1300px its grid rule gives three columns; one column is what unstyled, block-flow cards give. The harness loaded with waitUntil: 'domcontentloaded', which does not wait for a stylesheet on a page without scripts. It now waits for load, then for .analytics-charts to compute to display: grid, and fails with a message naming style.css if it never does, so a real CSS regression still fails.

To be clear about confidence: I could not reproduce the failure locally (10/10 passes, also with 20× CPU throttling), and among recent failed deploy runs only this one hit it. If you would rather keep that file out of this PR, I can drop the commit and ask for a re-run instead.

CI run 36735899064 failed "viewport 1440 / wrapper 1300px → side-by-side
(≥2 cols): expected ≥2 columns at wrapper 1300px; got 1". At 1300px the
grid rule (repeat(auto-fit, minmax(min(100%, 380px), 1fr))) gives three
columns; one column is what block-flow, unstyled cards give, so the
cards were measured before style.css applied. The harness loaded with
waitUntil 'domcontentloaded', which does not wait for a stylesheet on a
page without scripts.

It now waits for 'load', then for .analytics-charts to compute to
display:grid, and fails with a message naming style.css if it never
does, so a real CSS regression still fails.

Not reproduced locally: 10/10 passes before the change, including with
20x CPU throttling; only this run failed on it among recent failed
deploy runs.

Confidence: medium
Scope-risk: narrow
Not-tested: the CI-runner timing that produced the failure
@sylr
sylr force-pushed the fix/packets-compact-columns branch from 4f0806d to 541bde6 Compare September 30, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants