From 1a5df04ab853bd8a674ebca9f74b3a6687a72030 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 6 Sep 2026 15:02:14 -0400 Subject: [PATCH 1/2] fix(analytics): preserve selected hash size in links (#1967) Red commit: `5a5ecb6` (local browser: 14 passed, 8 behavior assertion failures before the fix). Hash Issues links now restore `bytes=1|2|3` for the selected control and its matrix/collision data. Missing or malformed values default to one byte. Selector clicks, section/top links, tab-bar changes, filters and theme refreshes retain the chosen view through the existing URL helper. Fixes #1914. - E2E assertion added: `test-issue-1306-collisions-terminology-e2e.js:242`. The existing CI-selected harness passes 23 checks, including distinct nonempty collision rows for each byte size. Its original assertions remain. - Browser verified: `http://127.0.0.1:55634` with the local fixture API, plus reviewed matrix/risk screenshots. Region refresh passed; area coverage skips because the fixture has no areas. - Required frontend checks pass: 99 filter, 18 aging, 666 helpers; URL helpers pass 18. Three independent reviews found no blocking issues; their coverage suggestion is included in `fb482fe`. - Added work parses URL state and updates six links. Rendering and bulk requests are reused; no backend, configuration, dependency or CI-list changes. - A broader smoke run timed out at Live autocomplete (#1110); full-suite success is not established. ## Preflight overrides - The external `run-all.sh` is absent. Corresponding scope, PII, syntax, whitespace and CSS checks passed; no SQL, migration or image changes require those gates. - Red browser evidence is local. Upstream CI execution remains a separate approval gate, as discussed in #1922. (cherry picked from commit eb1d733998ac9c2f3254b7a309a67593c78d66d9) Co-Authored-By: Claude Opus 5 --- public/analytics.js | 26 ++- test-issue-1306-collisions-terminology-e2e.js | 164 +++++++++++++++++- 2 files changed, 179 insertions(+), 11 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 846ca04bb..373bbcd28 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1514,18 +1514,18 @@ async function renderCollisionTab(el, data, collisionData) { el.innerHTML = `

Collisions actually observed in packet traffic — among repeaters grouped by their configured hash size. For theoretical address conflicts that would occur if all repeaters used a given hash size, see the Prefix Tool tab.

-

Inconsistent Hash Sizes

↑ top
+

Inconsistent Hash Sizes

↑ top

Repeaters and room servers sending adverts with varying hash sizes in the last 7 days. Originally caused by a firmware bug where automatic adverts ignored the configured multibyte path setting, fixed in repeater v1.14.1. Companion nodes are excluded.

Loading…
@@ -1533,7 +1533,7 @@

Hash Usage Matrix

- ↑ top + ↑ top
@@ -1547,7 +1547,7 @@
-

Collision Risk

↑ top
+

Collision Risk

↑ top
Loading…
`; @@ -1584,9 +1584,15 @@ // Repeaters and routing nodes no longer needed — collision data is server-computed - let currentBytes = 1; function refreshHashViews(bytes) { - currentBytes = bytes; + // #1914: keep both the bookmark and section links on this byte size. + if (window.URLState) { + const newHash = URLState.updateHashParams({ bytes }, location.hash); + if (newHash !== location.hash) history.replaceState(null, '', newHash); + el.querySelectorAll('[data-hash-section]').forEach(link => { + link.href = URLState.updateHashParams({ section: link.dataset.hashSection }, newHash); + }); + } hideMatrixTip(); // Update selector button states document.querySelectorAll('.hash-byte-btn').forEach(b => { @@ -1615,7 +1621,9 @@ btn.addEventListener('click', () => refreshHashViews(Number(btn.dataset.bytes))); }); - refreshHashViews(1); + // Read on every render so tab, filter and theme refreshes retain the view. + const urlBytes = new URLSearchParams(location.hash.split('?')[1] || '').get('bytes'); + refreshHashViews(['1', '2', '3'].includes(urlBytes) ? Number(urlBytes) : 1); } function renderHashTimeline(hourly) { diff --git a/test-issue-1306-collisions-terminology-e2e.js b/test-issue-1306-collisions-terminology-e2e.js index 1905b25f3..688ce2aca 100644 --- a/test-issue-1306-collisions-terminology-e2e.js +++ b/test-issue-1306-collisions-terminology-e2e.js @@ -15,6 +15,8 @@ * 3. The Collisions (Hash Issues) tab body contains the reverse * cross-reference link back to `#/analytics?tab=prefix-tool` * framed around "actually observed" / packet traffic wording. + * 4. #1914: shared Hash Issues links restore the selected byte size and + * its server data; selection survives navigation and refreshes. * * Usage: BASE_URL=http://localhost:13581 node test-issue-1306-collisions-terminology-e2e.js */ @@ -32,6 +34,7 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } async function openPrefixTool(page) { await page.goto(BASE + '/#/analytics?tab=prefix-tool', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__collisionsThemeReady); await page.waitForSelector('#ptOverview', { timeout: 15000 }); // expand the overview body (collapsed by default) await page.evaluate(() => { @@ -40,9 +43,45 @@ async function openPrefixTool(page) { }); } -async function openCollisions(page) { - await page.goto(BASE + '/#/analytics?tab=collisions', { waitUntil: 'domcontentloaded' }); +async function openCollisions(page, query = '') { + await page.goto('about:blank'); + await page.goto(BASE + '/#/analytics?tab=collisions' + query, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#hashMatrixSection', { timeout: 15000 }); + await page.waitForFunction(() => document.querySelector('#hashMatrix .analytics-stat-value')); +} + +function hashParams(page) { + return new URLSearchParams(new URL(page.url()).hash.split('?')[1] || ''); +} + +async function refreshCollisions(page, action) { + const previous = await page.locator('#hashMatrix').elementHandle(); + await action(); + await page.waitForFunction(el => !el.isConnected, previous); + await previous.dispose(); +} + +async function assertHashView(page, bytes, collisionData) { + await page.waitForFunction(() => window.__collisionsThemeReady); + await page.waitForFunction(() => document.querySelector('#hashMatrix .analytics-stat-value')); + const selected = page.locator('#hashByteSelector .hash-byte-btn.active'); + assert(await selected.count() === 1, 'Exactly one byte-size button should be selected'); + const actual = await selected.getAttribute('data-bytes'); + assert(actual === String(bytes), `Expected ${bytes}-byte selection, got ${actual}`); + const riskTitle = await page.locator('#collisionRiskTitle').textContent(); + assert(riskTitle.includes(bytes + '-Byte Collision Risk'), 'Risk title does not match selected byte size'); + const usingCard = page.locator('#hashMatrix .analytics-stat-card').nth(1); + assert((await usingCard.textContent()).includes('Using ' + bytes + '-byte ID'), 'Matrix uses the wrong byte-size data'); + const expected = collisionData.by_size[String(bytes)]; + assert(!!expected, 'Fixture must include ' + bytes + '-byte collision data'); + const usingCount = Number((await usingCard.locator('.analytics-stat-value').textContent()).replace(/,/g, '')); + assert(usingCount === (expected.stats.using_this_size || 0), 'Matrix count differs from the selected server data'); + assert(await page.locator('#hashMatrix .hash-matrix-table').count() === (bytes === 3 ? 0 : 1), 'Expected a grid only for 1-byte and 2-byte views'); + const prefixes = await page.locator('#collisionList tbody tr > td:first-child').allTextContents(); + assert(JSON.stringify(prefixes) === JSON.stringify((expected.collisions || []).map(c => c.prefix)), 'Collision risk rows differ from the selected server data'); + if (!prefixes.length) { + assert((await page.locator('#collisionList').textContent()).includes('No ' + bytes + '-byte'), 'Empty collision result should name the selected byte size'); + } } (async () => { @@ -64,6 +103,11 @@ async function openCollisions(page) { } const ctx = await browser.newContext({ viewport: { width: 1400, height: 1200 } }); + // Initial theme loading rebuilds analytics once; wait for it before + // interacting with an expanded panel or asserting the restored view. + await ctx.addInitScript(() => { + window.addEventListener('theme-refresh', () => { window.__collisionsThemeReady = true; }, { once: true }); + }); const page = await ctx.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', (e) => console.error('[pageerror]', e.message)); @@ -108,6 +152,122 @@ async function openCollisions(page) { assert(xref >= 1, 'Collisions tab missing cross-reference link to #/analytics?tab=prefix-tool'); }); + const collisionResponse = await page.request.get(BASE + '/api/analytics/hash-collisions'); + assert(collisionResponse.ok(), 'Local collision API must respond successfully'); + const collisionData = await collisionResponse.json(); + + for (const section of ['hashMatrixSection', 'collisionRiskSection']) { + for (const bytes of [1, 2, 3]) { + await step(`#1914: ${bytes}-byte ${section} deep link restores selection and data`, async () => { + await openCollisions(page, '§ion=' + section + '&bytes=' + bytes); + await assertHashView(page, bytes, collisionData); + assert(hashParams(page).get('section') === section, 'Deep link must retain the target section'); + }); + } + } + + await step('#1914: byte clicks update a reloadable URL without losing other parameters', async () => { + await openCollisions(page, '§ion=collisionRiskSection&window=7d&filter=kept'); + for (const bytes of [2, 3, 1]) { + await page.locator(`.hash-byte-btn[data-bytes="${bytes}"]`).click(); + const params = hashParams(page); + assert(params.get('bytes') === String(bytes), `Byte click should write bytes=${bytes}, got ${params.get('bytes')}`); + assert(params.get('section') === 'collisionRiskSection' && params.get('window') === '7d' && params.get('filter') === 'kept', 'Byte click must preserve other parameters'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await assertHashView(page, bytes, collisionData); + } + }); + + await step('#1914: section and top links preserve byte size and other parameters', async () => { + await openCollisions(page, '&bytes=3&window=7d&filter=kept'); + for (const section of ['hashMatrixSection', 'collisionRiskSection', 'inconsistentHashSection']) { + const link = page.locator(`#hashIssuesToc a[href*="section=${section}"]`); + const params = new URLSearchParams((await link.getAttribute('href')).split('?')[1]); + assert(params.get('bytes') === '3' && params.get('window') === '7d' && params.get('filter') === 'kept', 'Section href must carry the selected view for copying or opening'); + await link.click(); + await assertHashView(page, 3, collisionData); + assert(hashParams(page).get('section') === section, 'Section click must update its URL target'); + } + await page.locator('#collisionRiskSection a', { hasText: 'top' }).click(); + await assertHashView(page, 3, collisionData); + assert(!hashParams(page).has('section') && hashParams(page).get('bytes') === '3', 'Top link should clear only the section'); + }); + + await step('#1914: time-window, tab and theme refreshes retain the chosen byte size', async () => { + await openCollisions(page); + await page.locator('.hash-byte-btn[data-bytes="2"]').click(); + await refreshCollisions(page, () => page.locator('#analyticsTimeWindow').selectOption('24h')); + await page.waitForFunction(() => new URLSearchParams(location.hash.split('?')[1]).get('window') === '24h'); + await assertHashView(page, 2, collisionData); + await page.locator('#analyticsTabs [data-tab="hashsizes"]').click(); + await page.locator('#analyticsTabs [data-tab="collisions"]').click(); + await assertHashView(page, 2, collisionData); + await page.evaluate(() => window.dispatchEvent(new Event('theme-refresh'))); + await assertHashView(page, 2, collisionData); + assert(hashParams(page).get('bytes') === '2' && hashParams(page).get('window') === '24h', 'Refreshes must preserve the view URL'); + }); + + for (const value of [null, '', '0', '4', '-1', '2.5', '2bytes', '02', 'NaN']) { + await step(`#1914: ${value === null ? 'missing' : JSON.stringify(value)} byte size safely defaults to 1`, async () => { + await openCollisions(page, value === null ? '' : '&bytes=' + encodeURIComponent(value)); + await assertHashView(page, 1, collisionData); + }); + } + + for (const filter of ['region', 'area']) { + await openCollisions(page, '&bytes=3'); + const container = page.locator(filter === 'region' ? '#analyticsRegionFilter' : '#analyticsAreaFilter'); + const option = container.locator(`[data-${filter}]:not([data-${filter}="__all__"])`).first(); + const config = await (await page.request.get(BASE + '/api/config/' + filter + 's')).json(); + if (filter === 'region' ? Object.keys(config).length < 2 : config.length === 0) { + console.log(` #1914 ${filter} refresh: SKIP (local fixture has no selectable ${filter} filter)`); + continue; + } + await step(`#1914: ${filter} filter refresh retains byte size and selects its server data`, async () => { + await option.waitFor({ state: 'attached' }); + const selected = await option.getAttribute('data-' + filter); + const dropdown = container.locator('.region-dropdown-trigger'); + if (await dropdown.count()) await dropdown.click(); + await refreshCollisions(page, () => option.click()); + const response = await page.request.get(BASE + '/api/analytics/hash-collisions?' + filter + '=' + encodeURIComponent(selected)); + assert(response.ok(), 'Filtered collision API must respond successfully'); + await assertHashView(page, 3, await response.json()); + assert(hashParams(page).get('bytes') === '3', 'Filter refresh must preserve the byte-size URL'); + if (await dropdown.count()) await dropdown.click(); + await refreshCollisions(page, () => container.locator(`[data-${filter}="__all__"]`).click()); + await assertHashView(page, 3, collisionData); + }); + } + + await step('#1914: nonempty risk rows follow deep-linked and clicked byte sizes', async () => { + const prefixesBySize = { 1: ['A1'], 2: ['B2C3', 'B2D4'], 3: ['D4E5F6'] }; + const routePattern = '**/api/analytics/hash-collisions*'; + await page.route(routePattern, async route => { + const response = await route.fetch(); + const json = await response.json(); + // Keep the real matrix data, but distinguish each risk list even + // when the local fixture has no observed collisions. + for (const [bytes, prefixes] of Object.entries(prefixesBySize)) { + json.by_size[bytes].collisions = prefixes.map(prefix => ({ + prefix, appearances: 1, with_coords: 0, classification: 'unknown', nodes: [] + })); + } + await route.fulfill({ response, json }); + }); + try { + await openCollisions(page, '§ion=collisionRiskSection&bytes=2'); + await page.waitForFunction(() => window.__collisionsThemeReady); + for (const bytes of [2, 3, 1]) { + if (bytes !== 2) await page.locator(`.hash-byte-btn[data-bytes="${bytes}"]`).click(); + const prefixes = await page.locator('#collisionList tbody tr > td:first-child').allTextContents(); + assert(JSON.stringify(prefixes) === JSON.stringify(prefixesBySize[bytes]), + `Expected ${bytes}-byte risk prefixes ${prefixesBySize[bytes]}, got ${prefixes}`); + } + } finally { + await page.unroute(routePattern); + } + }); + await browser.close(); console.log('\n' + passed + ' passed, ' + failed + ' failed'); From 6ff6e7b582dcc8fe29596352bfc2a71706f191ae Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Sun, 20 Sep 2026 12:40:01 +0200 Subject: [PATCH 2/2] reviewfix(analytics): sync the hash-size URL after rendering, not before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review flagged that the URL sync ran first inside refreshHashViews, ahead of the button states, titles and both render calls, so a throwing history.replaceState (Safari throttles it; it is unavailable on an opaque origin) would take the whole tab down rather than just the URL update. I could not reproduce that outcome: with a stub that throws for every 'bytes=' URL, both the old and the new ordering render the tab fully with zero page errors, because the existing 'newHash !== location.hash' guard means the deep-link path never calls replaceState at all. So this is not a fix for an observed break — it is the ordering the code should have had either way: the rendered views are the feature, the URL sync is a convenience, and nothing in the render path depends on it. Extracted into syncHashUrl() and called last, with the reasoning in a comment. No test is added: a test asserting a behavioural difference here would be asserting one I could not demonstrate. The existing 23 assertions still pass. Co-Authored-By: Claude Opus 5 --- public/analytics.js | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index cf555e49e..f18e5fe10 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1595,15 +1595,21 @@ // Repeaters and routing nodes no longer needed — collision data is server-computed + // #1914: keep both the bookmark and the section links on this byte size. + // Deliberately not part of the rendering path, and called last: + // history.replaceState can throw (Safari throttles it, and it is + // unavailable on an opaque origin), and a URL-sync failure must not take + // the whole tab down with it — the views have already rendered by then. + function syncHashUrl(bytes) { + if (!window.URLState) return; + const newHash = URLState.updateHashParams({ bytes }, location.hash); + if (newHash !== location.hash) history.replaceState(null, '', newHash); + el.querySelectorAll('[data-hash-section]').forEach(link => { + link.href = URLState.updateHashParams({ section: link.dataset.hashSection }, newHash); + }); + } + function refreshHashViews(bytes) { - // #1914: keep both the bookmark and section links on this byte size. - if (window.URLState) { - const newHash = URLState.updateHashParams({ bytes }, location.hash); - if (newHash !== location.hash) history.replaceState(null, '', newHash); - el.querySelectorAll('[data-hash-section]').forEach(link => { - link.href = URLState.updateHashParams({ section: link.dataset.hashSection }, newHash); - }); - } hideMatrixTip(); // Update selector button states document.querySelectorAll('.hash-byte-btn').forEach(b => { @@ -1625,6 +1631,7 @@ const riskCard = document.getElementById('collisionRiskSection'); if (riskCard) riskCard.style.display = ''; renderCollisionsFromServer(cData.by_size[String(bytes)], bytes); + syncHashUrl(bytes); } // Wire up selector