@@ -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 @@
@@ -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 = `
Failed to load: ${esc(e.message)}
`;
+ 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
+
+
+ ${regionNote}
+
+
+
Total nodes
+
${totalNodes.toLocaleString()}
+
+ ${[1, 2, 3].map(b => `
+
+
${b}-byte prefixes
+
+ ${stats[b].usedPrefixes.toLocaleString()}
+ / ${spaceSizes[b].toLocaleString()}
+
+
+ ${stats[b].collidingPrefixes === 0
+ ? 'β
No collisions'
+ : `β οΈ ${stats[b].collidingPrefixes} prefix${stats[b].collidingPrefixes !== 1 ? 'es' : ''} collide`}
+
+
`).join('')}
+
+
+ Recommendation: ${rec} prefixes β ${recDetail}
+ Hash size is configured per-node in firmware. Changing requires reflashing.
+
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
`;
+
+ // --- 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 `
`;
+ }
+
+ 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.
+
+
`;
+ 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 });
})();