diff --git a/public/analytics.js b/public/analytics.js index f45fe726..934f9edf 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -86,6 +86,7 @@ +
@@ -173,6 +174,7 @@ case 'nodes': await renderNodesTab(el); break; case 'distance': await renderDistanceTab(el); break; case 'neighbor-graph': await renderNeighborGraphTab(el); break; + case 'prefix-tool': await renderPrefixTool(el); break; } // Auto-apply column resizing to all analytics tables requestAnimationFrame(() => { @@ -985,6 +987,8 @@ πŸ”’ Hash Matrix | πŸ’₯ Collision Risk + | + πŸ”Ž Check a prefix β†’
@@ -2302,5 +2306,294 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ _ngState.animId = requestAnimationFrame(tick); } + // --- Prefix Tool --- + async function renderPrefixTool(el) { + el.innerHTML = '
Loading prefix data…
'; + + const rq = RegionFilter.regionQueryString(); + const regionLabel = rq ? (new URLSearchParams(rq.slice(1)).get('region') || '') : ''; + + let nodesResp; + try { + nodesResp = await api('/nodes?limit=10000&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }); + } catch (e) { + el.innerHTML = ``; + return; + } + + // Deduplicate by public_key, require at least 6 hex chars to build all 3 tiers + const nodeMap = new Map(); + (nodesResp.nodes || nodesResp).forEach(n => { + if (n.public_key && n.public_key.length >= 6 && !nodeMap.has(n.public_key)) { + nodeMap.set(n.public_key, n); + } + }); + const nodes = [...nodeMap.values()]; + + if (nodes.length === 0) { + el.innerHTML = `

No nodes in the network yet. Any prefix is available!

`; + return; + } + + // Build 3-tier prefix indexes: prefix (uppercase hex) -> [nodes] + const idx = { 1: new Map(), 2: new Map(), 3: new Map() }; + nodes.forEach(n => { + const pk = n.public_key.toUpperCase(); + [1, 2, 3].forEach(b => { + const p = pk.slice(0, b * 2); + if (!idx[b].has(p)) idx[b].set(p, []); + idx[b].get(p).push(n); + }); + }); + + // Network overview stats + const spaceSizes = { 1: 256, 2: 65536, 3: 16777216 }; + const stats = {}; + [1, 2, 3].forEach(b => { + stats[b] = { + usedPrefixes: idx[b].size, + collidingPrefixes: [...idx[b].values()].filter(arr => arr.length > 1).length, + }; + }); + + // Recommendation by network size + const totalNodes = nodes.length; + let rec, recDetail; + if (totalNodes < 20) { + rec = '1-byte'; recDetail = `With only ${totalNodes} nodes, 1-byte prefixes have low collision risk.`; + } else if (totalNodes < 500) { + rec = '2-byte'; recDetail = `With ${totalNodes} nodes, 2-byte prefixes are recommended to avoid collisions.`; + } else { + rec = '2-byte'; recDetail = `With ${totalNodes} nodes, 2-byte prefixes are strongly recommended.`; + } + + // URL params for pre-fill / auto-run + const hashParams = new URLSearchParams((location.hash.split('?')[1] || '')); + const initPrefix = hashParams.get('prefix') || ''; + const initGenerate = hashParams.get('generate') || ''; + + const regionNote = regionLabel + ? `

Showing data for region: ${esc(regionLabel)}. Check all nodes β†’

` + : ''; + + el.innerHTML = ` +
+
+ β–Ά +

Network Overview

+
+ +
+ +
+

Check a Prefix

+

Enter a 1-byte (2 hex chars), 2-byte (4 hex chars), or 3-byte (6 hex chars) prefix β€” or paste a full public key.

+
+ + +
+
+
+ +
+

Generate Available Prefix

+

Find a prefix with zero current collisions.

+
+ + + + +
+
+
+ πŸ“– New to multi-byte prefixes? + + Read the MeshCore FAQ on multi-byte support β†’ + +
+
`; + + // --- Helpers --- + function nodeEntry(n) { + const name = esc(n.name || n.public_key.slice(0, 12)); + const role = n.role ? `${esc(n.role)}` : ''; + const when = n.last_seen ? ` ${new Date(n.last_seen).toLocaleDateString()}` : ''; + return `
${name} ${role}${when}
`; + } + + function severityBadge(count) { + if (count === 0) return 'βœ… Unique'; + if (count <= 2) return `⚠️ ${count} collision${count !== 1 ? 's' : ''}`; + return `πŸ”΄ ${count} collisions`; + } + + // --- Checker --- + function doCheck(raw) { + const resultsEl = document.getElementById('ptCheckerResults'); + if (!resultsEl) return; + const input = raw.trim().toUpperCase(); + if (!input) { resultsEl.innerHTML = ''; return; } + + if (!/^[0-9A-F]+$/.test(input)) { + resultsEl.innerHTML = '

Invalid input β€” hex characters only (0-9, A-F).

'; + return; + } + if (input.length % 2 !== 0 || (input.length !== 2 && input.length !== 4 && input.length !== 6 && input.length < 8)) { + resultsEl.innerHTML = '

Prefix must be 2, 4, or 6 hex characters. For a full public key, use 64 characters.

'; + return; + } + + const isFullKey = input.length >= 8; + const tiers = isFullKey + ? [{ b: 1, prefix: input.slice(0, 2) }, { b: 2, prefix: input.slice(0, 4) }, { b: 3, prefix: input.slice(0, 6) }] + : [{ b: input.length / 2, prefix: input }]; + + let html = ''; + if (isFullKey) { + const inNetwork = nodes.some(n => n.public_key.toUpperCase() === input); + html += `

Derived prefixes: ${input.slice(0,2)} / ${input.slice(0,4)} / ${input.slice(0,6)}${!inNetwork ? ' β€” this node is not yet in the network' : ''}

`; + } + + tiers.forEach(({ b, prefix }) => { + const matches = idx[b].get(prefix) || []; + const colliders = isFullKey ? matches.filter(n => n.public_key.toUpperCase() !== input) : matches; + const count = colliders.length; + html += ` +
+
+ ${prefix} + ${b}-byte + ${severityBadge(count)} +
+ ${count === 0 + ? '
No existing nodes use this prefix.
' + : `
${colliders.map(nodeEntry).join('')}
`} +
`; + }); + + resultsEl.innerHTML = html; + } + + // --- Generator --- + function doGenerate() { + const genResultEl = document.getElementById('ptGenResult'); + if (!genResultEl) return; + const sizeInput = el.querySelector('input[name="ptGenSize"]:checked'); + const b = sizeInput ? parseInt(sizeInput.value) : 2; + const hexLen = b * 2; + const totalSpace = spaceSizes[b]; + const available = totalSpace - idx[b].size; + + if (available === 0) { + const next = b < 3 ? (b + 1) + '-byte' : 'a different size'; + genResultEl.innerHTML = `

No collision-free ${b}-byte prefixes available. Try ${next}.

`; + return; + } + + let prefix; + if (b === 1) { + // Enumerate all 256 options + const free = []; + for (let i = 0; i < totalSpace; i++) { + const p = i.toString(16).toUpperCase().padStart(hexLen, '0'); + if (!idx[b].has(p)) free.push(p); + } + prefix = free[Math.floor(Math.random() * free.length)]; + } else { + // Random sampling β€” with 2K used / 65K space, hit rate >96% + let attempts = 0; + do { + prefix = Math.floor(Math.random() * totalSpace).toString(16).toUpperCase().padStart(hexLen, '0'); + } while (idx[b].has(prefix) && ++attempts < 500); + // Fallback to enumeration if sampling kept hitting used prefixes + if (idx[b].has(prefix)) { + for (let i = 0; i < totalSpace; i++) { + const p = i.toString(16).toUpperCase().padStart(hexLen, '0'); + if (!idx[b].has(p)) { prefix = p; break; } + } + } + } + + genResultEl.innerHTML = ` +
+
+ ${prefix} + βœ… No existing nodes use this prefix +
+
${available.toLocaleString()} of ${totalSpace.toLocaleString()} ${b}-byte prefixes are available.
+
+ + + Generate key with this prefix β†’ + +
+
`; + document.getElementById('ptRegenBtn').addEventListener('click', doGenerate); + } + + // --- Wire up --- + const checkBtn = document.getElementById('ptCheckBtn'); + const prefixInput = document.getElementById('ptPrefixInput'); + const genBtn = document.getElementById('ptGenBtn'); + + checkBtn.addEventListener('click', () => doCheck(prefixInput.value)); + prefixInput.addEventListener('keydown', e => { if (e.key === 'Enter') doCheck(prefixInput.value); }); + genBtn.addEventListener('click', doGenerate); + + // Network Overview toggle + document.getElementById('ptOverviewToggle').addEventListener('click', () => { + const body = document.getElementById('ptOverviewBody'); + const chevron = document.getElementById('ptOverviewChevron'); + const open = body.style.display === 'none'; + body.style.display = open ? '' : 'none'; + chevron.style.transform = open ? 'rotate(90deg)' : ''; + }); + + // Auto-run from URL params + if (initPrefix) { + doCheck(initPrefix); + setTimeout(() => { document.getElementById('ptChecker')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150); + } else if (initGenerate) { + doGenerate(); + setTimeout(() => { document.getElementById('ptGenerator')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150); + } + } + registerPage('analytics', { init, destroy }); })();