diff --git a/public/analytics.js b/public/analytics.js index 9478cded7..f18e5fe10 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1525,18 +1525,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…
@@ -1544,7 +1544,7 @@

Hash Usage Matrix

- ↑ top + ↑ top
@@ -1558,7 +1558,7 @@
-

Collision Risk

↑ top
+

Collision Risk

↑ top
Loading…
`; @@ -1595,9 +1595,21 @@ // Repeaters and routing nodes no longer needed — collision data is server-computed - let currentBytes = 1; + // #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) { - currentBytes = bytes; hideMatrixTip(); // Update selector button states document.querySelectorAll('.hash-byte-btn').forEach(b => { @@ -1619,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 @@ -1626,7 +1639,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');