Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions public/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -1525,26 +1525,26 @@
async function renderCollisionTab(el, data, collisionData) {
el.innerHTML = `
<nav id="hashIssuesToc" style="display:flex;gap:12px;margin-bottom:12px;font-size:13px;flex-wrap:wrap">
<a href="#/analytics?tab=collisions&section=inconsistentHashSection" style="color:var(--link-color)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Inconsistent Sizes</a>
<a data-hash-section="inconsistentHashSection" href="#/analytics?tab=collisions&section=inconsistentHashSection" style="color:var(--link-color)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Inconsistent Sizes</a>
<span style="color:var(--border)">|</span>
<a href="#/analytics?tab=collisions&section=hashMatrixSection" style="color:var(--link-color)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-list-numbers"/></svg> Hash Matrix</a>
<a data-hash-section="hashMatrixSection" href="#/analytics?tab=collisions&section=hashMatrixSection" style="color:var(--link-color)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-list-numbers"/></svg> Hash Matrix</a>
<span style="color:var(--border)">|</span>
<a href="#/analytics?tab=collisions&section=collisionRiskSection" style="color:var(--link-color)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-bomb"/></svg> Collision Risk</a>
<a data-hash-section="collisionRiskSection" href="#/analytics?tab=collisions&section=collisionRiskSection" style="color:var(--link-color)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-bomb"/></svg> Collision Risk</a>
<span style="color:var(--border)">|</span>
<a href="#/analytics?tab=prefix-tool" style="color:var(--link-color)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Check a prefix →</a>
</nav>
<p class="text-muted" style="margin:0 0 12px;font-size:0.78em">Collisions <strong>actually observed in packet traffic</strong> — among <strong>repeaters</strong> grouped by their configured hash size. For <em>theoretical</em> address conflicts that <em>would</em> occur if all repeaters used a given hash size, see the <a href="#/analytics?tab=prefix-tool" style="color:var(--link-color)">Prefix Tool</a> tab.</p>

<div class="analytics-card" id="inconsistentHashSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Inconsistent Hash Sizes</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Inconsistent Hash Sizes</h3><a data-hash-section="" href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Repeaters and room servers sending adverts with varying hash sizes in the last 7 days. Originally caused by a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--link-color)">firmware bug</a> where automatic adverts ignored the configured multibyte path setting, fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--link-color)">repeater v1.14.1</a>. Companion nodes are excluded.</p>
<div id="inconsistentHashList"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading…</div></div>
</div>

<div class="analytics-card" id="hashMatrixSection">
<div style="display:flex;justify-content:space-between;align-items:center">
<h3 style="margin:0" id="hashMatrixTitle"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-list-numbers"/></svg> Hash Usage Matrix</h3>
<a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a>
<a data-hash-section="" href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a>
</div>
<div style="display:flex;align-items:center;gap:16px;margin:8px 0">
<div class="hash-byte-selector" id="hashByteSelector" style="display:flex;gap:4px">
Expand All @@ -1558,7 +1558,7 @@
</div>

<div class="analytics-card" id="collisionRiskSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0" id="collisionRiskTitle"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-bomb"/></svg> Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0" id="collisionRiskTitle"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-bomb"/></svg> Collision Risk</h3><a data-hash-section="" href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading…</div></div>
</div>
`;
Expand Down Expand Up @@ -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 => {
Expand All @@ -1619,14 +1631,17 @@
const riskCard = document.getElementById('collisionRiskSection');
if (riskCard) riskCard.style.display = '';
renderCollisionsFromServer(cData.by_size[String(bytes)], bytes);
syncHashUrl(bytes);
}

// Wire up selector
document.getElementById('hashByteSelector')?.querySelectorAll('.hash-byte-btn').forEach(btn => {
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) {
Expand Down
164 changes: 162 additions & 2 deletions test-issue-1306-collisions-terminology-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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(() => {
Expand All @@ -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 () => {
Expand All @@ -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));
Expand Down Expand Up @@ -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, '&section=' + 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, '&section=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, '&section=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');
Expand Down
Loading