/* === CoreScope — analytics.js (v2 — full nerd mode) === */ 'use strict'; (function () { let _analyticsData = {}; const sf = (v, d) => (v != null ? v.toFixed(d) : '–'); // safe toFixed function esc(s) { return s ? String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''') : ''; } // #1085 — Roles tab helpers (hoisted from renderRolesTab so they're not // re-allocated per render). function _rolesEmoji(role) { if (window.ROLE_EMOJI && window.ROLE_EMOJI[role]) return window.ROLE_EMOJI[role]; return '•'; } function _rolesFmtSec(v) { if (!v && v !== 0) return '—'; var abs = Math.abs(v); if (abs < 1) return v.toFixed(2) + 's'; if (abs < 60) return v.toFixed(1) + 's'; if (abs < 3600) return (v / 60).toFixed(1) + 'm'; if (abs < 86400) return (v / 3600).toFixed(1) + 'h'; return (v / 86400).toFixed(1) + 'd'; } // #1085 — auto-refresh timer for the Roles tab. Started when the Roles // tab is rendered, cleared on tab switch and destroy. var _rolesRefreshTimer = null; function _stopRolesRefresh() { if (_rolesRefreshTimer) { clearInterval(_rolesRefreshTimer); _rolesRefreshTimer = null; } } // --- Status color helpers (read from CSS variables for theme support) --- function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } function statusGreen() { return cssVar('--status-green') || '#22c55e'; } function statusYellow() { return cssVar('--status-yellow') || '#eab308'; } function statusRed() { return cssVar('--status-red') || '#ef4444'; } function accentColor() { return cssVar('--accent') || '#4a9eff'; } function snrColor(snr) { return snr > 6 ? statusGreen() : snr > 0 ? statusYellow() : statusRed(); } // --- SVG helpers --- function sparkSvg(data, color, w = 120, h = 32) { if (!data.length) return ''; const max = Math.max(...data, 1); const pts = data.map((v, i) => { const x = i * (w / Math.max(data.length - 1, 1)); const y = h - 2 - (v / max) * (h - 4); return `${x},${y}`; }).join(' '); return `Sparkline showing trend of ${data.length} data points`; } function barChart(data, labels, colors, w = 800, h = 220, pad = 40) { const max = Math.max(...data, 1); const barW = Math.max(1, Math.min((w - pad * 2) / data.length - 2, 30)); let svg = `Bar chart showing data distribution`; // Grid for (let i = 0; i <= 4; i++) { const y = pad + (h - pad * 2) * i / 4; const val = Math.round(max * (4 - i) / 4); svg += ``; svg += `${val}`; } data.forEach((v, i) => { const x = pad + i * ((w - pad * 2) / data.length) + barW / 2; const bh = (v / max) * (h - pad * 2); const y = h - pad - bh; const c = typeof colors === 'string' ? colors : colors[i % colors.length]; svg += ``; if (labels[i]) svg += `${labels[i]}`; }); svg += ''; return svg; } function histogram(data, bins, color, w = 800, h = 180) { // Support pre-computed histogram from server { bins: [{x, w, count}], min, max } if (data && data.bins && Array.isArray(data.bins)) { const buckets = data.bins.map(b => b.count); const labels = data.bins.map(b => b.x.toFixed(1)); return { svg: barChart(buckets, labels, color, w, h), buckets, labels }; } // Legacy: raw values array const values = data; const min = Math.min(...values), max = Math.max(...values); const step = (max - min) / bins; const buckets = Array(bins).fill(0); const labels = []; for (let i = 0; i < bins; i++) labels.push((min + step * i).toFixed(1)); values.forEach(v => { const b = Math.min(Math.floor((v - min) / step), bins - 1); buckets[b]++; }); return { svg: barChart(buckets, labels, color, w, h), buckets, labels }; } // --- Main --- async function init(app) { app.innerHTML = `

📊 Mesh Analytics

Deep dive into your mesh network data

Loading analytics…
`; // Tab handling const analyticsTabs = document.getElementById('analyticsTabs'); initTabBar(analyticsTabs); // #749 — keep analytics tab + window in URL for deep-linking. function _updateAnalyticsUrl() { if (!window.URLState) return; var twElNow = document.getElementById('analyticsTimeWindow'); var updates = { tab: _currentTab && _currentTab !== 'overview' ? _currentTab : '', window: twElNow && twElNow.value ? twElNow.value : '' }; // Drop any subview-specific keys that don't belong to the active tab // so switching tabs gives a clean URL. (rf-health uses 'range', 'observer', 'from', 'to') if (_currentTab !== 'rf-health') { var cleared = ['range', 'observer', 'from', 'to']; for (var i = 0; i < cleared.length; i++) updates[cleared[i]] = ''; } var newHash = URLState.updateHashParams(updates, location.hash); if (newHash !== location.hash) history.replaceState(null, '', newHash); } analyticsTabs.addEventListener('click', e => { const btn = e.target.closest('.tab-btn'); if (!btn) return; document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); _currentTab = btn.dataset.tab; // #1085 — Roles tab owns its own 60s auto-refresh; stop it on switch. if (_currentTab !== 'roles') _stopRolesRefresh(); _updateAnalyticsUrl(); renderTab(_currentTab); }); // Deep-link: #/analytics?tab=collisions&window=7d const hashParams = location.hash.split('?')[1] || ''; const _ap = new URLSearchParams(hashParams); const urlTab = _ap.get('tab'); if (urlTab) { const tabBtn = analyticsTabs.querySelector(`[data-tab="${urlTab}"]`); if (tabBtn) { analyticsTabs.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); tabBtn.classList.add('active'); _currentTab = urlTab; } } // #749 — restore time window from URL. const urlWindow = _ap.get('window'); if (urlWindow) { const twInit = document.getElementById('analyticsTimeWindow'); if (twInit) twInit.value = urlWindow; } RegionFilter.init(document.getElementById('analyticsRegionFilter')); RegionFilter.onChange(function () { loadAnalytics(); }); // Time-window picker (#842) — refresh analytics on change. const tw = document.getElementById('analyticsTimeWindow'); if (tw) { tw.addEventListener('change', function () { _updateAnalyticsUrl(); loadAnalytics(); }); } // Delegated click/keyboard handler for clickable table rows const analyticsContent = document.getElementById('analyticsContent'); if (analyticsContent) { const handler = (e) => { const row = e.target.closest('tr[data-action="navigate"]'); if (!row) return; if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; if (e.type === 'keydown') e.preventDefault(); location.hash = row.dataset.value; }; analyticsContent.addEventListener('click', handler); analyticsContent.addEventListener('keydown', handler); } // Re-render when distance unit or theme changes _themeRefreshHandler = function () { renderTab(_currentTab); }; window.addEventListener('theme-refresh', _themeRefreshHandler); loadAnalytics(); } var _themeRefreshHandler = null; let _currentTab = 'overview'; async function loadAnalytics() { try { _analyticsData = {}; const rqs = RegionFilter.regionQueryString(); // "®ion=..." or "" // Time window picker (#842) — append &window=… when set. // NOTE: only the three window-aware endpoints (rf/topology/channels) // receive ?window=…; hash-sizes and hash-collisions are about node // identity / hash-byte distribution and intentionally span all data. const twEl = document.getElementById('analyticsTimeWindow'); const twVal = twEl ? twEl.value : ''; const tws = twVal ? '&window=' + encodeURIComponent(twVal) : ''; const baseQS = rqs.slice(1); // drop leading '&', "" or "region=…" const sepBase = baseQS ? '?' + baseQS : ''; const windowedQS = (rqs + tws).slice(1); const sepWin = windowedQS ? '?' + windowedQS : ''; const [hashData, rfData, topoData, chanData, collisionData] = await Promise.all([ api('/analytics/hash-sizes' + sepBase, { ttl: CLIENT_TTL.analyticsRF }), api('/analytics/rf' + sepWin, { ttl: CLIENT_TTL.analyticsRF }), api('/analytics/topology' + sepWin, { ttl: CLIENT_TTL.analyticsRF }), api('/analytics/channels' + sepWin, { ttl: CLIENT_TTL.analyticsRF }), api('/analytics/hash-collisions' + sepBase, { ttl: CLIENT_TTL.analyticsRF }), ]); _analyticsData = { hashData, rfData, topoData, chanData, collisionData }; renderTab(_currentTab); } catch (e) { document.getElementById('analyticsContent').innerHTML = ``; } } async function renderTab(tab) { const el = document.getElementById('analyticsContent'); const d = _analyticsData; switch (tab) { case 'overview': renderOverview(el, d); break; case 'rf': renderRF(el, d.rfData); break; case 'topology': renderTopology(el, d.topoData); break; case 'channels': renderChannels(el, d.chanData); break; case 'hashsizes': renderHashSizes(el, d.hashData); break; case 'collisions': await renderCollisionTab(el, d.hashData, d.collisionData); break; case 'subpaths': await renderSubpaths(el); break; case 'nodes': await renderNodesTab(el); break; case 'distance': await renderDistanceTab(el); break; case 'neighbor-graph': await renderNeighborGraphTab(el); break; case 'rf-health': await renderRFHealthTab(el); break; case 'clock-health': await renderClockHealthTab(el); break; case 'roles': await renderRolesTab(el); break; case 'prefix-tool': await renderPrefixTool(el); break; } // Auto-apply column resizing to all analytics tables requestAnimationFrame(() => { el.querySelectorAll('.analytics-table').forEach((tbl, i) => { tbl.id = tbl.id || `analytics-tbl-${tab}-${i}`; if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`); }); // #206 — Wrap analytics tables in scroll containers on mobile el.querySelectorAll('.analytics-table').forEach(tbl => { if (!tbl.parentElement.classList.contains('analytics-table-scroll')) { const wrapper = document.createElement('div'); wrapper.className = 'analytics-table-scroll'; tbl.parentElement.insertBefore(wrapper, tbl); wrapper.appendChild(tbl); } }); }); // Deep-link scroll to section within tab const sectionId = new URLSearchParams((location.hash.split('?')[1] || '')).get('section'); if (sectionId) { setTimeout(() => { const target = document.getElementById(sectionId); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 400); } } // ===================== OVERVIEW ===================== function renderOverview(el, d) { const rf = d.rfData, topo = d.topoData, ch = d.chanData, hs = d.hashData; el.innerHTML = `
${(rf.totalTransmissions || rf.totalAllPackets || rf.totalPackets).toLocaleString()}
Total Transmissions
${sparkSvg(rf.packetsPerHour.map(h=>h.count), 'var(--accent)')}
${rf.totalPackets.toLocaleString()}
Observations with Signal
${topo.uniqueNodes}
Unique Nodes
${sf(rf.snr.avg, 1)} dB
Avg SNR
${sf(rf.snr.min, 1)} to ${sf(rf.snr.max, 1)}
${sf(rf.rssi.avg, 0)} dBm
Avg RSSI
${rf.rssi.min} to ${rf.rssi.max}
${sf(topo.avgHops, 1)}
Avg Hops
max ${topo.maxHops}
${ch.activeChannels}
Active Channels
${ch.decryptable} decryptable
${rf.avgPacketSize} B
Avg Packet Size
${rf.minPacketSize}–${rf.maxPacketSize} B
${((rf.timeSpanHours || 1)).toFixed(1)}h
Data Span

📈 Packets / Hour

${(() => { const pph = rf.packetsPerHour; const counts = pph.map(h => h.count); // Decimate x-axis labels to avoid overlap const totalHours = pph.length; // Pick label interval: <=24h show every 6h, <=72h every 12h, else every 24h const labelInterval = totalHours <= 24 ? 6 : totalHours <= 72 ? 12 : 24; const labels = pph.map((h, i) => { const hh = h.hour.slice(11, 13); // "HH" const hourNum = parseInt(hh, 10); if (hourNum % labelInterval === 0) { // For multi-day ranges, show date on 00h boundaries if (totalHours > 48 && hourNum === 0) return h.hour.slice(5, 10); return hh + 'h'; } return ''; // skip label }); return barChart(counts, labels, 'var(--accent)'); })()}

📦 Payload Type Mix

${renderPayloadPie(rf.payloadTypes)}

🔗 Hop Count Distribution

${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
`; // Affinity stats widget — fetch and append if debugAffinity enabled var showDebug = (window.CLIENT_CONFIG && window.CLIENT_CONFIG.debugAffinity) || localStorage.getItem('meshcore-affinity-debug') === 'true'; if (showDebug) { var apiKey = localStorage.getItem('meshcore-api-key') || ''; fetch('/api/debug/affinity', { headers: { 'X-API-Key': apiKey } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { if (!data || !data.stats) return; var s = data.stats; var total = s.resolvedCount + s.ambiguousCount + s.unresolvedCount; var resolvedPct = total > 0 ? (s.resolvedCount / total * 100).toFixed(1) : '0.0'; var ambiguousPct = total > 0 ? (s.ambiguousCount / total * 100).toFixed(1) : '0.0'; var widget = document.createElement('div'); widget.className = 'analytics-row'; widget.innerHTML = '
' + '

🔍 Neighbor Affinity Graph

' + '
' + '
' + s.totalEdges + '
Total Edges
' + '
' + s.totalNodes + '
Total Nodes
' + '
' + s.resolvedCount + ' (' + resolvedPct + '%)
Resolved Prefixes
' + '
' + s.ambiguousCount + ' (' + ambiguousPct + '%)
Ambiguous Prefixes
' + '
' + (s.avgConfidence || 0).toFixed(3) + '
Avg Confidence
' + '
' + (s.coldStartCoverage || 0).toFixed(1) + '%
Cold-Start Coverage
' + '
' + (s.cacheAge || 'N/A') + '
Cache Age
' + '
' + (s.lastRebuild ? s.lastRebuild.substring(0, 19) : 'N/A') + '
Last Rebuild
' + '
'; el.appendChild(widget); }) .catch(function () {}); } } function renderPayloadPie(types) { const total = types.reduce((s, t) => s + t.count, 0); const colors = ['#ef4444','#f59e0b','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6','#64748b','#f97316','#06b6d4','#84cc16']; let html = '
'; types.forEach((t, i) => { const pct = (t.count / total * 100).toFixed(1); const w = Math.max(t.count / total * 100, 1); html += `
${t.name}
${t.count} (${pct}%)
`; }); return html + '
'; } // ===================== RF / SIGNAL ===================== function renderRF(el, rf) { const snrHist = histogram(rf.snrValues, 20, statusGreen()); const rssiHist = histogram(rf.rssiValues, 20, accentColor()); el.innerHTML = `

📶 SNR Distribution

Signal-to-Noise Ratio (higher = cleaner signal)

${snrHist.svg}
Min: ${sf(rf.snr.min, 1)} dB Mean: ${sf(rf.snr.avg, 1)} dB Median: ${sf(rf.snr.median, 1)} dB Max: ${sf(rf.snr.max, 1)} dB σ: ${sf(rf.snr.stddev, 1)} dB

📡 RSSI Distribution

Received Signal Strength (closer to 0 = stronger)

${rssiHist.svg}
Min: ${rf.rssi.min} dBm Mean: ${sf(rf.rssi.avg, 0)} dBm Median: ${rf.rssi.median} dBm Max: ${rf.rssi.max} dBm σ: ${sf(rf.rssi.stddev, 1)} dBm

🎯 SNR vs RSSI Scatter

Each dot = one packet. Cluster position reveals link quality.

${renderScatter(rf.scatterData)}

📊 SNR by Payload Type

${renderSNRByType(rf.snrByType)}

📈 Signal Quality Over Time

${renderSignalTimeline(rf.signalOverTime)}

📏 Packet Size Distribution

Raw packet length in bytes

${histogram(rf.packetSizes, 25, '#8b5cf6').svg}
Min: ${rf.minPacketSize} B Avg: ${rf.avgPacketSize} B Max: ${rf.maxPacketSize} B
`; } function renderScatter(data) { const w = 600, h = 300, pad = 40; const snrMin = -12, snrMax = 15, rssiMin = -130, rssiMax = -5; let svg = `SNR vs RSSI scatter plot showing signal quality distribution`; // Axes svg += ``; svg += ``; svg += `SNR (dB)`; svg += `RSSI (dBm)`; // Grid labels for (let snr = -10; snr <= 14; snr += 4) { const x = pad + (snr - snrMin) / (snrMax - snrMin) * (w - pad * 2); svg += `${snr}`; } for (let rssi = -120; rssi <= -20; rssi += 20) { const y = h - pad - (rssi - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2); svg += `${rssi}`; } // Quality zones const _sg = statusGreen(), _sy = statusYellow(), _sr = statusRed(); const zones = [ { label: 'Excellent', snr: [6, 15], rssi: [-80, -5], color: _sg + '20' }, { label: 'Good', snr: [0, 6], rssi: [-100, -80], color: _sy + '15' }, { label: 'Weak', snr: [-12, 0], rssi: [-130, -100], color: _sr + '10' }, ]; // Define patterns for color-blind accessibility svg += ``; svg += ``; svg += ``; svg += ``; svg += ``; const zonePatterns = { 'Excellent': 'pat-excellent', 'Good': 'pat-good', 'Weak': 'pat-weak' }; const zoneDash = { 'Excellent': '4,2', 'Good': '6,3', 'Weak': '2,2' }; const zoneBorder = { 'Excellent': _sg, 'Good': _sy, 'Weak': _sr }; zones.forEach(z => { const x1 = pad + (z.snr[0] - snrMin) / (snrMax - snrMin) * (w - pad * 2); const x2 = pad + (z.snr[1] - snrMin) / (snrMax - snrMin) * (w - pad * 2); const y1 = h - pad - (z.rssi[1] - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2); const y2 = h - pad - (z.rssi[0] - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2); svg += ``; svg += ``; svg += ``; svg += `${z.label}`; }); // Dots (sample if too many) const sample = data.length > 500 ? data.filter((_, i) => i % Math.ceil(data.length / 500) === 0) : data; sample.forEach(d => { const x = pad + (d.snr - snrMin) / (snrMax - snrMin) * (w - pad * 2); const y = h - pad - (d.rssi - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2); svg += ``; }); svg += ''; return svg; } function renderSNRByType(snrByType) { if (!snrByType.length) return '
No data
'; let html = ''; snrByType.forEach(t => { const barPct = Math.max(((t.avg - (-12)) / 27) * 100, 2); const color = t.avg > 6 ? statusGreen() : t.avg > 0 ? statusYellow() : statusRed(); html += ``; }); return html + '
TypePacketsAvg SNRMinMaxDistribution
${t.name} ${t.count} ${sf(t.avg, 1)} dB ${sf(t.min, 1)} ${sf(t.max, 1)}
'; } function renderSignalTimeline(data) { if (!data.length) return '
No data
'; const w = 400, h = 160, pad = 35; const maxPkts = Math.max(...data.map(d => d.count), 1); let svg = `Signal quality over time showing SNR trend and packet volume`; const snrPts = data.map((d, i) => { const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1)); const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2); return `${x},${y}`; }).join(' '); svg += ``; // Packet count as area const areaPts = data.map((d, i) => { const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1)); const y = h - pad - (d.count / maxPkts) * (h - pad * 2) * 0.4; return `${x},${y}`; }); const baseline = data.map((_, i) => { const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1)); return `${x},${h - pad}`; }); svg += ``; // Labels const step = Math.max(1, Math.floor(data.length / 6)); for (let i = 0; i < data.length; i += step) { const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1)); svg += `${data[i].hour.slice(11)}h`; } svg += ''; svg += `
Avg SNRVolume
`; return svg; } // ===================== TOPOLOGY ===================== function renderTopology(el, topo) { el.innerHTML = `

🔗 Hop Count Distribution

Number of repeater hops per packet

${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
Avg: ${sf(topo.avgHops, 1)} hops Median: ${topo.medianHops} Max: ${topo.maxHops} 1-hop direct: ${topo.hopDistribution[0]?.count || 0}

🕸️ Top Repeaters

Nodes appearing most in packet paths

${renderRepeaterTable(topo.topRepeaters)}

🤝 Repeater Pair Heatmap

Which repeaters frequently appear together in paths

${renderPairTable(topo.topPairs)}

📊 Hops vs SNR

Does more hops = worse signal?

${renderHopsSNR(topo.hopsVsSnr)}

🏆 Best Path to Each Node

Shortest hop distance seen across all observers

${renderBestPath(topo.bestPathList)}

🌐 Per-Observer Reachability

Nodes at each hop distance, from each observer's perspective

${topo.observers.length > 1 ? `
${topo.observers.map((o, i) => ``).join('')}
` : ''}
${renderPerObserverReach(topo.perObserverReach, topo.observers[0]?.id)}
${topo.multiObsNodes.length ? `

🔀 Cross-Observer Comparison

Nodes seen by multiple observers — hop distance varies by vantage point

${renderCrossObserver(topo.multiObsNodes)}
` : ''} `; // Observer selector event handling const selector = document.getElementById('obsSelector'); if (selector) { initTabBar(selector); selector.addEventListener('click', e => { const btn = e.target.closest('.tab-btn'); if (!btn) return; selector.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const obsId = btn.dataset.obs; document.getElementById('reachContent').innerHTML = obsId === '__all' ? renderAllObserversReach(topo.perObserverReach) : renderPerObserverReach(topo.perObserverReach, obsId); }); } } function renderRepeaterTable(repeaters) { if (!repeaters.length) return '
No data
'; const max = repeaters[0].count; let html = '
'; repeaters.slice(0, 15).forEach(r => { const pct = (r.count / max * 100).toFixed(0); html += `
${r.name ? '' + esc(r.name) + '' : '' + r.hop + ''}
${r.count.toLocaleString()}
`; }); return html + '
'; } function renderPairTable(pairs) { if (!pairs.length) return '
Not enough multi-hop data
'; let html = ''; pairs.slice(0, 12).forEach(p => { html += ``; }); return html + '
Node ANode BCo-appearances
${p.nameA ? `${esc(p.nameA)}` : `${p.hopA}`} ${p.nameB ? `${esc(p.nameB)}` : `${p.hopB}`} ${p.count}
'; } function renderHopsSNR(data) { if (!data.length) return '
No data
'; const w = 380, h = 160, pad = 40; const maxHop = Math.max(...data.map(d => d.hops)); let svg = `Hops vs SNR bubble chart showing signal degradation over distance`; data.forEach(d => { const x = pad + (d.hops / maxHop) * (w - pad * 2); const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2); const r = Math.min(Math.sqrt(d.count) * 1.5, 12); const color = d.avgSnr > 6 ? statusGreen() : d.avgSnr > 0 ? statusYellow() : statusRed(); svg += ``; svg += `${d.hops}h`; }); svg += `Hops`; svg += `Avg SNR`; svg += ''; return svg; } function renderPerObserverReach(perObserverReach, obsId) { const data = perObserverReach[obsId]; if (!data || !data.rings.length) return '
No path data for this observer
'; let html = `
`; data.rings.forEach(ring => { const nodeLinks = ring.nodes.slice(0, 8).map(n => { const label = n.name ? `${esc(n.name)}` : `${n.hop}`; const detail = n.distRange ? ` (${n.distRange})` : ''; return label + detail; }).join(', '); const extra = ring.nodes.length > 8 ? ` +${ring.nodes.length - 8} more` : ''; html += `
${ring.hops} hop${ring.hops > 1 ? 's' : ''}
${nodeLinks}${extra}
${ring.nodes.length} node${ring.nodes.length > 1 ? 's' : ''}
`; }); return html + '
'; } function renderAllObserversReach(perObserverReach) { let html = ''; for (const [obsId, data] of Object.entries(perObserverReach)) { html += `

📡 ${esc(data.observer_name)}

`; html += renderPerObserverReach(perObserverReach, obsId); } return html || '
No data
'; } function renderCrossObserver(nodes) { if (!nodes.length) return '
No nodes seen by multiple observers
'; let html = ``; nodes.forEach(n => { const name = n.name ? `${esc(n.name)}` : `${n.hop}`; const obsInfo = n.observers.map(o => `${esc(o.observer_name)}: ${o.minDist} hop${o.minDist > 1 ? 's' : ''}(${o.count} pkts)` ).join('
'); html += ``; }); return html + '
NodeObserversHop Distances
${name}${n.observers.length}${obsInfo}
'; } function renderBestPath(nodes) { if (!nodes.length) return '
No data
'; // Group by distance for a cleaner view const byDist = {}; nodes.forEach(n => { if (!byDist[n.minDist]) byDist[n.minDist] = []; byDist[n.minDist].push(n); }); let html = '
'; Object.entries(byDist).sort((a, b) => +a[0] - +b[0]).forEach(([dist, nodes]) => { const nodeLinks = nodes.slice(0, 10).map(n => { const label = n.name ? `${esc(n.name)}` : `${n.hop}`; return label + ` via ${esc(n.observer_name)}`; }).join(', '); const extra = nodes.length > 10 ? ` +${nodes.length - 10} more` : ''; html += `
${dist} hop${+dist > 1 ? 's' : ''}
${nodeLinks}${extra}
${nodes.length} node${nodes.length > 1 ? 's' : ''}
`; }); return html + '
'; } // ===================== CHANNELS ===================== var _channelSortState = null; var _channelData = null; var _channelRenderGen = 0; var CHANNEL_SORT_KEY = 'meshcore-channel-sort'; function loadChannelSort() { try { var s = localStorage.getItem(CHANNEL_SORT_KEY); if (s) { var p = JSON.parse(s); if (p.col && p.dir) return p; } } catch (e) {} return { col: 'lastActivity', dir: 'desc' }; } // True when the user has explicitly chosen a sort (saved in localStorage). // Used by the grouped analytics view to decide whether to apply its own // default ("messages desc") instead of the global flat-list default. function hasSavedChannelSort() { try { var s = localStorage.getItem(CHANNEL_SORT_KEY); if (!s) return false; var p = JSON.parse(s); return !!(p && p.col && p.dir); } catch (e) { return false; } } function saveChannelSort(state) { try { localStorage.setItem(CHANNEL_SORT_KEY, JSON.stringify(state)); } catch (e) {} } function sortChannels(channels, col, dir) { var sorted = channels.slice(); var mult = dir === 'asc' ? 1 : -1; sorted.sort(function (a, b) { var av, bv; switch (col) { case 'name': av = (a.name || '').toLowerCase(); bv = (b.name || '').toLowerCase(); return av < bv ? -1 * mult : av > bv ? 1 * mult : 0; case 'hash': av = typeof a.hash === 'number' ? a.hash : String(a.hash); bv = typeof b.hash === 'number' ? b.hash : String(b.hash); if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * mult; av = String(av).toLowerCase(); bv = String(bv).toLowerCase(); return av < bv ? -1 * mult : av > bv ? 1 * mult : 0; case 'messages': return (a.messages - b.messages) * mult; case 'senders': return (a.senders - b.senders) * mult; case 'lastActivity': av = a.lastActivity || ''; bv = b.lastActivity || ''; return av < bv ? -1 * mult : av > bv ? 1 * mult : 0; case 'encrypted': av = a.encrypted ? 1 : 0; bv = b.encrypted ? 1 : 0; return (av - bv) * mult; default: return 0; } }); return sorted; } function channelRowHtml(c) { var name = c.displayName || c.name || 'Unknown'; return '' + '' + esc(name) + '' + '' + (typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash) + '' + '' + c.messages + '' + '' + c.senders + '' + '' + timeAgo(c.lastActivity) + '' + '' + (c.encrypted ? (c.group === 'mine' ? '🔑' : '🔒') : '✅') + '' + ''; } // ── PSK-aware decoration ────────────────────────────────────────────────── // Server returns raw "chNNN" placeholder names for encrypted channels it // doesn't know. Decorate so the UI shows a useful display name and a // group bucket: mine / network / encrypted. Pure function for testability. function decorateAnalyticsChannels(channels, hashByteToKeyName, labels) { var keyMap = hashByteToKeyName || {}; var lab = labels || {}; var out = []; for (var i = 0; i < (channels || []).length; i++) { var c = channels[i]; var copy = Object.assign({}, c); var hashNum = typeof c.hash === 'number' ? c.hash : parseInt(c.hash, 10); var rawName = String(c.name || ''); var isPlaceholder = /^ch(\d+|\?)$/.test(rawName); if (c.encrypted) { var keyName = !isNaN(hashNum) ? keyMap[hashNum] : null; if (keyName) { copy.displayName = lab[keyName] || keyName; copy.group = 'mine'; } else if (isPlaceholder || !rawName) { // Placeholder ("chNNN") or empty name → render as opaque encrypted. // Empty-name encrypted rows would otherwise leak through with an // empty in the row; force the placeholder rendering. copy.displayName = !isNaN(hashNum) ? '🔒 Encrypted (0x' + hashNum.toString(16).toUpperCase().padStart(2, '0') + ')' : '🔒 Encrypted'; copy.group = 'encrypted'; } else { // Server gave us a real name (rainbow table hit) for an encrypted ch. copy.displayName = rawName; copy.group = 'network'; } } else { copy.displayName = rawName || 'Unknown'; copy.group = 'network'; } out.push(copy); } return out; } // Build the (hash byte → key name) map from ChannelDecrypt's stored keys. // Async because computeChannelHash uses subtle.digest. Returns {} if the // module or its keys are unavailable (graceful fallback). async function buildHashKeyMap() { if (typeof ChannelDecrypt === 'undefined' || !ChannelDecrypt.getStoredKeys) return {}; var keys = ChannelDecrypt.getStoredKeys(); var map = {}; var names = Object.keys(keys || {}); for (var ni = 0; ni < names.length; ni++) { var name = names[ni]; try { var bytes = ChannelDecrypt.hexToBytes(keys[name]); var hb = await ChannelDecrypt.computeChannelHash(bytes); if (typeof hb === 'number') map[hb] = name; } catch (e) { /* skip bad key */ } } return map; } function channelTbodyHtml(channels, col, dir, opts) { var sorted = sortChannels(channels, col, dir); var parts = []; if (opts && opts.grouped) { // Group by .group: mine → network → encrypted. Inside each group keep // the active sort (caller passes col/dir; for the integration we sort // by messages desc by default). var groups = { mine: [], network: [], encrypted: [] }; for (var gi = 0; gi < sorted.length; gi++) { var g = sorted[gi].group || (sorted[gi].encrypted ? 'encrypted' : 'network'); (groups[g] || (groups[g] = [])).push(sorted[gi]); } var sections = [ { key: 'mine', label: '🔑 My Channels' }, { key: 'network', label: '📻 Network' }, { key: 'encrypted', label: '🔒 Encrypted' }, ]; for (var si = 0; si < sections.length; si++) { var rows = groups[sections[si].key] || []; if (!rows.length) continue; parts.push( '' + esc(sections[si].label) + ' (' + rows.length + ')' + '' ); for (var ri = 0; ri < rows.length; ri++) parts.push(channelRowHtml(rows[ri])); } } else { for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i])); } return parts.join(''); } function channelSortArrow(col, activeCol, dir) { if (col !== activeCol) return ''; return '' + (dir === 'asc' ? '↑' : '↓') + ''; } function channelTheadHtml(activeCol, dir) { var cols = [ { key: 'name', label: 'Channel' }, { key: 'hash', label: 'Hash' }, { key: 'messages', label: 'Messages' }, { key: 'senders', label: 'Unique Senders' }, { key: 'lastActivity', label: 'Last Activity' }, { key: 'encrypted', label: 'Decrypted' }, ]; var ths = ''; for (var i = 0; i < cols.length; i++) { var c = cols[i]; ths += '' + c.label + channelSortArrow(c.key, activeCol, dir) + ''; } return '' + ths + ''; } function updateChannelTable() { var tbody = document.getElementById('channelsTbody'); var thead = document.querySelector('#channelsTable thead'); if (!tbody || !_channelData) return; tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true }); if (thead) thead.outerHTML = channelTheadHtml(_channelSortState.col, _channelSortState.dir); } function renderChannels(el, ch) { // Decorate first so grouping/display name reflect locally-stored PSK keys. // buildHashKeyMap is async; render once with a sync best-effort empty map, // then upgrade once keys resolve. That keeps first paint fast and avoids // blocking on subtle.digest in environments where it's slow. var rawChannels = ch.channels || []; // Resolve the persisted sort first so the default-fallback below doesn't // shadow what the user previously chose. Default for the grouped view is // messages desc (matches the PR description); only used when nothing saved. if (!_channelSortState) { _channelSortState = hasSavedChannelSort() ? loadChannelSort() : { col: 'messages', dir: 'desc' }; } var ranOnce = false; // Generation token: if renderChannels is called again before // buildHashKeyMap() resolves, the older promise must not clobber the // newer rawChannels / decoration with stale-key data. var myGen = ++_channelRenderGen; function applyDecorate(map) { if (myGen !== _channelRenderGen) return; // superseded var labels = (typeof ChannelDecrypt !== 'undefined' && ChannelDecrypt.getLabels) ? ChannelDecrypt.getLabels() : {}; _channelData = decorateAnalyticsChannels(rawChannels, map, labels); if (ranOnce) updateChannelTable(); } applyDecorate({}); ranOnce = true; buildHashKeyMap().then(applyDecorate).catch(function () { /* graceful */ }); var timelineHtml = renderChannelTimeline(ch.channelTimeline); var topSendersHtml = renderTopSenders(ch.topSenders); var histoHtml = ch.msgLengths.length ? histogram(ch.msgLengths, 20, '#8b5cf6').svg : '
No decrypted messages
'; el.innerHTML = '
' + '

📻 Channel Activity

' + '

' + ch.activeChannels + ' active channels, ' + ch.decryptable + ' decryptable

' + '' + channelTheadHtml(_channelSortState.col, _channelSortState.dir) + '' + channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true }) + '' + '
' + '
' + '
' + '
' + '

💬 Messages / Hour by Channel

' + timelineHtml + '
' + '
' + '

🗣️ Top Senders

' + topSendersHtml + '
' + '
' + '
' + '

📊 Message Length Distribution

' + histoHtml + '
'; // Attach sort handler via delegation on the table var table = document.getElementById('channelsTable'); if (table) { table.addEventListener('click', function (e) { var th = e.target.closest('th[data-sort-col]'); if (!th) return; var col = th.dataset.sortCol; if (_channelSortState.col === col) { _channelSortState.dir = _channelSortState.dir === 'asc' ? 'desc' : 'asc'; } else { _channelSortState.col = col; _channelSortState.dir = col === 'name' || col === 'hash' ? 'asc' : 'desc'; } saveChannelSort(_channelSortState); updateChannelTable(); }); } } var CHANNEL_TIMELINE_MAX_SERIES = 8; function renderChannelTimeline(data) { if (!data.length) return '
No data
'; var hours = []; var hourSet = {}; var channelList = []; var channelSet = {}; var lookup = {}; var channelVolume = {}; for (var i = 0; i < data.length; i++) { var d = data[i]; if (!hourSet[d.hour]) { hourSet[d.hour] = 1; hours.push(d.hour); } if (!channelSet[d.channel]) { channelSet[d.channel] = 1; channelList.push(d.channel); } lookup[d.hour + '|' + d.channel] = d.count; channelVolume[d.channel] = (channelVolume[d.channel] || 0) + d.count; } hours.sort(); // Sort channels by total volume descending, cap to top N channelList.sort(function(a, b) { return channelVolume[b] - channelVolume[a]; }); var hiddenCount = Math.max(0, channelList.length - CHANNEL_TIMELINE_MAX_SERIES); var visibleChannels = channelList.slice(0, CHANNEL_TIMELINE_MAX_SERIES); var maxCount = 1; for (var vi = 0; vi < visibleChannels.length; vi++) { for (var hi2 = 0; hi2 < hours.length; hi2++) { var c = lookup[hours[hi2] + '|' + visibleChannels[vi]] || 0; if (c > maxCount) maxCount = c; } } var colors = ['#ef4444','#22c55e','#3b82f6','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#64748b']; var w = 600, h = 180, pad = 35; var xScale = (w - pad * 2) / Math.max(hours.length - 1, 1); var yScale = (h - pad * 2) / maxCount; var svg = 'Channel message activity over time'; for (var ci = 0; ci < visibleChannels.length; ci++) { var pts = []; for (var hi = 0; hi < hours.length; hi++) { var count = lookup[hours[hi] + '|' + visibleChannels[ci]] || 0; var x = pad + hi * xScale; var y = h - pad - count * yScale; pts.push(x + ',' + y); } svg += ''; } var step = Math.max(1, Math.floor(hours.length / 6)); for (var li = 0; li < hours.length; li += step) { var lx = pad + li * xScale; svg += '' + hours[li].slice(11) + 'h'; } svg += ''; var legendParts = []; for (var lci = 0; lci < visibleChannels.length; lci++) { legendParts.push('' + esc(visibleChannels[lci]) + ''); } if (hiddenCount > 0) { legendParts.push('+' + hiddenCount + ' more'); } svg += '
' + legendParts.join('') + '
'; return svg; } function renderTopSenders(senders) { if (!senders.length) return '
No decrypted messages
'; const max = senders[0].count; let html = '
'; senders.slice(0, 10).forEach(s => { html += `
${esc(s.name)}
${s.count} msgs
`; }); return html + '
'; } // ===================== HASH SIZES (original) ===================== function renderHashSizes(el, data) { const d = data.distribution; const total = data.total; const pct = (n) => total ? (n / total * 100).toFixed(1) : '0'; const maxCount = Math.max(d[1] || 0, d[2] || 0, d[3] || 0, 1); el.innerHTML = `

Hash Size Distribution

${total.toLocaleString()} packets with path hops

${[1, 2, 3].map(size => { const count = d[size] || 0; const width = Math.max((count / maxCount) * 100, count ? 2 : 0); const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' }; return `
${size}-byte (${size * 8}-bit, ${Math.pow(256, size).toLocaleString()} IDs)
${count.toLocaleString()} (${pct(count)}%)
`; }).join('')}
${data.distributionByRepeaters ? (() => { const dr = data.distributionByRepeaters; const totalRepeaters = (dr[1] || 0) + (dr[2] || 0) + (dr[3] || 0); const rpct = (n) => totalRepeaters ? (n / totalRepeaters * 100).toFixed(1) : '0'; const maxRepeaters = Math.max(dr[1] || 0, dr[2] || 0, dr[3] || 0, 1); const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' }; return `

By Repeaters

${totalRepeaters.toLocaleString()} unique repeaters

${[1, 2, 3].map(size => { const count = dr[size] || 0; const width = Math.max((count / maxRepeaters) * 100, count ? 2 : 0); return `
${size}-byte
${count.toLocaleString()} (${rpct(count)}%)
`; }).join('')}
`; })() : ''}

📈 Hash Size Over Time

${renderHashTimeline(data.hourly)}
${renderMultiByteAdopters(data.multiByteNodes, data.multiByteCapability || [])}

Top Path Hops

${data.topHops.map(h => { const link = h.pubkey ? `#/nodes/${encodeURIComponent(h.pubkey)}` : `#/packets?search=${h.hex}`; return ``; }).join('')}
HopNodeBytesAppearances
${h.hex} ${h.name ? `${esc(h.name)}` : 'unknown'} ${h.size}-byte ${h.count.toLocaleString()}
`; } function renderMultiByteAdopters(nodes, caps) { // Merge capability status into adopter nodes var capByPubkey = {}; (caps || []).forEach(function(c) { capByPubkey[c.pubkey] = c; }); var statusIcon = { confirmed: '✅', suspected: '⚠️', unknown: '❓' }; var statusLabel = { confirmed: 'Confirmed', suspected: 'Suspected', unknown: 'Unknown' }; var statusColor = { confirmed: 'var(--success, #22c55e)', suspected: 'var(--warning, #eab308)', unknown: 'var(--text-muted, #888)' }; // Build merged rows: each adopter node gets a capability status var rows = (nodes || []).map(function(n) { var cap = capByPubkey[n.pubkey] || {}; return { name: n.name, pubkey: n.pubkey || '', role: n.role || '', hashSize: n.hashSize, packets: n.packets, lastSeen: n.lastSeen, status: cap.status || 'unknown', evidence: cap.evidence || '' }; }); // Count statuses var counts = { confirmed: 0, suspected: 0, unknown: 0 }; rows.forEach(function(r) { counts[r.status] = (counts[r.status] || 0) + 1; }); function buildTableContent(rows, filter) { var filtered = filter === 'all' ? rows : rows.filter(function(r) { return r.status === filter; }); return (filtered.length ? '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + filtered.map(function(r) { var roleColor = (window.ROLE_COLORS || {})[r.role] || '#6b7280'; return '' + '' + '' + '' + '' + '' + '' + ''; }).join('') + '' + '
NodeRoleStatusHash SizeAdvertsLast Seen
' + esc(r.name) + '' + esc(r.role || 'unknown') + '' + (statusIcon[r.status] || '❓') + ' ' + (statusLabel[r.status] || 'Unknown') + '' + r.hashSize + '-byte' + r.packets + '' + (r.lastSeen ? timeAgo(r.lastSeen) : '—') + '
' : '
No adopters match this filter.
'); } if (!rows.length) return '
' + '

Multi-Byte Hash Adopters

' + '
No multi-byte adopters found
'; var html = '
' + '
' + '
' + '

Multi-Byte Hash Adopters

' + '

Nodes advertising with 2+ byte hash paths. ' + 'Confirmed = seen advertising with multi-byte hash. ' + 'Suspected = prefix appeared in a multi-byte path. ' + 'Unknown = no multi-byte evidence yet.

' + '
' + '
' + '' + '' + '' + '' + '
' + '
' + '
' + buildTableContent(rows, 'all') + '
' + '
'; // Use setTimeout for event delegation on the stable section container setTimeout(function() { var section = document.getElementById('mbAdoptersSection'); if (!section) return; var currentFilter = 'all'; section.addEventListener('click', function handler(e) { var btn = e.target.closest('[data-mb-filter]'); if (btn) { currentFilter = btn.dataset.mbFilter; // Update active state on buttons (no DOM replacement needed) var buttons = section.querySelectorAll('[data-mb-filter]'); buttons.forEach(function(b) { b.classList.toggle('active', b.dataset.mbFilter === currentFilter); }); // Replace only the table content, not the whole section var wrap = section.querySelector('#mbAdoptersTableWrap'); if (wrap) wrap.innerHTML = buildTableContent(rows, currentFilter); return; } var th = e.target.closest('[data-sort]'); if (th) { var tbody = section.querySelector('tbody'); if (!tbody) return; var sortRows = Array.from(tbody.querySelectorAll('tr')); var col = th.dataset.sort; var colIdx = { name: 0, status: 1, hashSize: 2, packets: 3, lastSeen: 4 }; var statusWeight = { 'confirmed': 0, 'suspected': 1, 'unknown': 2 }; sortRows.sort(function(a, b) { var va = a.children[colIdx[col]] ? a.children[colIdx[col]].textContent.trim() : ''; var vb = b.children[colIdx[col]] ? b.children[colIdx[col]].textContent.trim() : ''; if (col === 'status') { va = statusWeight[va.toLowerCase().split(' ').pop()] !== undefined ? statusWeight[va.toLowerCase().split(' ').pop()] : 2; vb = statusWeight[vb.toLowerCase().split(' ').pop()] !== undefined ? statusWeight[vb.toLowerCase().split(' ').pop()] : 2; } if (col === 'hashSize' || col === 'packets') { va = parseInt(va) || 0; vb = parseInt(vb) || 0; } if (va < vb) return -1; if (va > vb) return 1; return 0; }); sortRows.forEach(function(r) { tbody.appendChild(r); }); } }); }, 100); return html; } // Legacy alias for tests — delegates to renderMultiByteAdopters with empty nodes function renderMultiByteCapability(caps) { if (!caps.length) return ''; // Convert caps to adopter-style rows for backward compat var fakeNodes = caps.map(function(c) { return { name: c.name, pubkey: c.pubkey, role: c.role, hashSize: c.maxHashSize, packets: 0, lastSeen: c.lastSeen }; }); return renderMultiByteAdopters(fakeNodes, caps); } async function renderCollisionTab(el, data, collisionData) { el.innerHTML = `

This tab shows operational collisions among repeaters grouped by their configured hash size. The Prefix Tool checks all repeaters regardless of their configured hash size.

⚠️ 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…

🔢 Hash Usage Matrix

↑ top

Click a cell to see which nodes share that prefix.

💥 Collision Risk

↑ top
Loading…
`; // Use pre-computed collision data from server (no more /nodes?limit=2000 fetch) const cData = collisionData || { inconsistent_nodes: [], by_size: {} }; const inconsistent = cData.inconsistent_nodes || []; const ihEl = document.getElementById('inconsistentHashList'); if (ihEl) { if (!inconsistent.length) { ihEl.innerHTML = '
✅ No inconsistencies detected — all nodes are reporting consistent hash sizes.
'; } else { ihEl.innerHTML = `${inconsistent.map((n, i) => { const roleColor = window.ROLE_COLORS?.[n.role] || '#6b7280'; const prefix = n.hash_size ? n.public_key.slice(0, n.hash_size * 2).toUpperCase() : '?'; const sizeBadges = (Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : []).map(s => { const c = s >= 3 ? '#16a34a' : s === 2 ? '#86efac' : '#f97316'; const fg = s === 2 ? '#064e3b' : '#fff'; return '' + s + 'B'; }).join(' '); const stripe = i % 2 === 1 ? 'background:var(--row-stripe)' : ''; return ``; }).join('')}
NodeRoleCurrent HashSizes Seen
${esc(n.name || n.public_key.slice(0, 12))} ${n.role} ${prefix} (${n.hash_size || '?'}B) ${sizeBadges}

${inconsistent.length} node${inconsistent.length > 1 ? 's' : ''} affected. Click a node name to see which adverts have different hash sizes.

`; } } // Repeaters and routing nodes no longer needed — collision data is server-computed let currentBytes = 1; function refreshHashViews(bytes) { currentBytes = bytes; hideMatrixTip(); // Update selector button states document.querySelectorAll('.hash-byte-btn').forEach(b => { b.classList.toggle('active', Number(b.dataset.bytes) === bytes); }); // Update titles and description const matrixTitle = document.getElementById('hashMatrixTitle'); const matrixDesc = document.getElementById('hashMatrixDesc'); const riskTitle = document.getElementById('collisionRiskTitle'); if (matrixTitle) matrixTitle.textContent = bytes === 3 ? '🔢 Hash Usage Matrix' : `🔢 ${bytes}-Byte Hash Usage Matrix`; if (riskTitle) riskTitle.textContent = `💥 ${bytes}-Byte Collision Risk`; if (matrixDesc) { if (bytes === 1) matrixDesc.textContent = 'Click a cell to see which nodes share that 1-byte prefix.'; else if (bytes === 2) matrixDesc.textContent = 'Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.'; else matrixDesc.textContent = '3-byte prefix space is too large to visualize as a matrix — collision table is shown below.'; } renderHashMatrixFromServer(cData.by_size[String(bytes)], bytes); // Show collision risk section for all byte sizes const riskCard = document.getElementById('collisionRiskSection'); if (riskCard) riskCard.style.display = ''; renderCollisionsFromServer(cData.by_size[String(bytes)], bytes); } // Wire up selector document.getElementById('hashByteSelector')?.querySelectorAll('.hash-byte-btn').forEach(btn => { btn.addEventListener('click', () => refreshHashViews(Number(btn.dataset.bytes))); }); refreshHashViews(1); } function renderHashTimeline(hourly) { if (!hourly.length) return '
Not enough data
'; const w = 800, h = 180, pad = 35; const maxVal = Math.max(...hourly.map(h => Math.max(h[1] || 0, h[2] || 0, h[3] || 0)), 1); const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' }; let svg = `Hash size distribution over time showing 1-byte, 2-byte, and 3-byte hash trends`; for (const size of [1, 2, 3]) { const pts = hourly.map((d, i) => { const x = pad + i * ((w - pad * 2) / Math.max(hourly.length - 1, 1)); const y = h - pad - ((d[size] || 0) / maxVal) * (h - pad * 2); return `${x},${y}`; }).join(' '); if (hourly.some(d => d[size] > 0)) svg += ``; } const step = Math.max(1, Math.floor(hourly.length / 8)); for (let i = 0; i < hourly.length; i += step) { const x = pad + i * ((w - pad * 2) / Math.max(hourly.length - 1, 1)); svg += `${hourly[i].hour.slice(11)}h`; } svg += ''; svg += `
1-byte2-byte3-byte
`; return svg; } // Shared hover tooltip for hash matrix cells. // Called once per container — reads content from data-tip on each . // Single shared tooltip element for the entire hash matrix — avoids DOM accumulation on mode switch let _matrixTip = null; function getMatrixTip() { if (!_matrixTip) { _matrixTip = document.createElement('div'); _matrixTip.className = 'hash-matrix-tooltip'; _matrixTip.style.display = 'none'; document.body.appendChild(_matrixTip); } return _matrixTip; } function hideMatrixTip() { if (_matrixTip) _matrixTip.style.display = 'none'; } function initMatrixTooltip(el) { if (el._matrixTipInit) return; el._matrixTipInit = true; el.addEventListener('mouseover', e => { const td = e.target.closest('td[data-tip]'); if (!td) return; const tip = getMatrixTip(); tip.innerHTML = td.dataset.tip; tip.style.display = 'block'; }); el.addEventListener('mousemove', e => { if (!_matrixTip || _matrixTip.style.display === 'none') return; const x = e.clientX + 14, y = e.clientY + 14; _matrixTip.style.left = Math.min(x, window.innerWidth - _matrixTip.offsetWidth - 8) + 'px'; _matrixTip.style.top = Math.min(y, window.innerHeight - _matrixTip.offsetHeight - 8) + 'px'; }); el.addEventListener('mouseout', e => { if (e.target.closest('td[data-tip]') && !e.relatedTarget?.closest('td[data-tip]')) hideMatrixTip(); }); el.addEventListener('mouseleave', hideMatrixTip); } // --- Shared helpers for hash matrix rendering --- function hashStatCardsHtml(totalNodes, usingCount, sizeLabel, spaceSize, usedCount, collisionCount) { const pct = spaceSize > 0 && usedCount > 0 ? ((usedCount / spaceSize) * 100) : 0; const pctStr = spaceSize > 65536 ? pct.toFixed(6) : spaceSize > 256 ? pct.toFixed(3) : pct.toFixed(1); const spaceLabel = spaceSize >= 1e6 ? (spaceSize / 1e6).toFixed(1) + 'M' : spaceSize.toLocaleString(); return `
Nodes tracked
${totalNodes.toLocaleString()}
Using ${sizeLabel} ID
${usingCount.toLocaleString()}
Prefix space used
${pctStr}%
${usedCount > 256 ? usedCount + ' of ' : 'of '}${spaceLabel} possible
0 ? 'onclick="document.getElementById(\'collisionRiskSection\')?.scrollIntoView({behavior:\'smooth\',block:\'start\'})"' : ''} ${collisionCount > 0 ? 'title="Click to see collision details"' : ''}>
Prefix collisions
${collisionCount}${collisionCount > 0 ? ' ' : ''}
`; } function hashMatrixGridHtml(nibbles, cellSize, headerSize, cellDataFn) { let html = `
`; html += ``; for (const n of nibbles) html += ``; html += ''; for (let hi = 0; hi < 16; hi++) { html += ``; for (let lo = 0; lo < 16; lo++) { html += cellDataFn(nibbles[hi] + nibbles[lo], cellSize); } html += ''; } html += '
${n}
${nibbles[hi]}
'; return html; } function hashMatrixLegendHtml(labels) { return `
${labels.map(l => ` ${l.text}`).join('\n')}
`; } // --- Shared cell classification for hash matrix --- function classifyHashCell(count, isConfirmedCollision, isPossibleConflict) { if (count === 0) return { cls: 'hash-cell-empty', bg: '' }; if (!isConfirmedCollision && !isPossibleConflict) return { cls: 'hash-cell-taken', bg: '' }; if (isPossibleConflict) return { cls: 'hash-cell-possible', bg: '' }; const t = Math.min((count - 2) / 4, 1); return { cls: 'hash-cell-collision', bg: `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);` }; } function hashCellTd(hex, cellSize, cls, bg, count, tipHtml, fontWeight) { return `${hex}`; } function hashTooltipHtml(hexLabel, statusText, nodesHtml) { let html = `
${hexLabel}
${statusText}
`; if (nodesHtml) html += `
${nodesHtml}
`; return html; } function renderHashMatrixPanel(el, statCardsHtml, cellRendererFn, detailMaxWidth, legendLabels, clickHandlerFn) { const nibbles = '0123456789ABCDEF'.split(''); const cellSize = 36; const headerSize = 24; let html = statCardsHtml; html += hashMatrixGridHtml(nibbles, cellSize, headerSize, cellRendererFn); html += `
`; html += hashMatrixLegendHtml(legendLabels); el.innerHTML = html; initMatrixTooltip(el); el.querySelectorAll('.hash-active').forEach(td => { td.addEventListener('click', () => { clickHandlerFn(td); el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected')); td.classList.add('hash-selected'); }); }); } function renderHashMatrixFromServer(sizeData, bytes) { const el = document.getElementById('hashMatrix'); if (!sizeData) { el.innerHTML = '
No data
'; return; } const stats = sizeData.stats || {}; const totalNodes = stats.total_nodes || 0; // 3-byte: show a summary panel instead of a matrix if (bytes === 3) { el.innerHTML = hashStatCardsHtml(totalNodes, stats.using_this_size || 0, '3-byte', 16777216, stats.unique_prefixes || 0, stats.collision_count || 0) + `

The 3-byte prefix space (16.7M values) is too large to visualize as a grid.${(stats.collision_count || 0) > 0 ? ' See collision details below.' : ''}

` + `

ℹ️ This tab only counts collisions among repeaters configured for this hash size. The Prefix Tool checks all repeaters regardless of configured hash size.

`; return; } if (bytes === 1) { const oneByteCells = sizeData.one_byte_cells || {}; const oneByteCount = stats.using_this_size || 0; const oneUsed = Object.values(oneByteCells).filter(v => v.length > 0).length; const oneCollisions = Object.values(oneByteCells).filter(v => v.length > 1).length; renderHashMatrixPanel(el, hashStatCardsHtml(totalNodes, oneByteCount, '1-byte', 256, oneUsed, oneCollisions), (hex, cs) => { const nodes = oneByteCells[hex] || []; const count = nodes.length; const repeaterCount = nodes.filter(n => n.role === 'repeater').length; const isCollision = count >= 2 && repeaterCount >= 2; const isPossible = count >= 2 && !isCollision; const { cls, bg } = classifyHashCell(count, isCollision, isPossible); const nodeLabel = m => `
${esc(m.name||m.public_key.slice(0,12))}${!m.role ? ' (unknown role)' : ''}
`; const nodesPreview = nodes.slice(0,5).map(nodeLabel).join('') + (nodes.length > 5 ? `
+${nodes.length-5} more
` : ''); const tip = count === 0 ? hashTooltipHtml(`0x${hex}`, 'Available') : count === 1 ? hashTooltipHtml(`0x${hex}`, 'One node — no collision', nodeLabel(nodes[0])) : isPossible ? hashTooltipHtml(`0x${hex}`, `${count} nodes — POSSIBLE CONFLICT`, nodesPreview) : hashTooltipHtml(`0x${hex}`, `${count} nodes — COLLISION`, nodesPreview); return hashCellTd(hex, cs, cls, bg, count, tip, count >= 2 ? '700' : '400'); }, 400, [ {cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'Available'}, {cls: 'hash-cell-taken', text: 'One node'}, {cls: 'hash-cell-possible', text: 'Possible conflict'}, {cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'} ], (td) => { const hex = td.dataset.hex.toUpperCase(); const matches = oneByteCells[hex] || []; const detail = document.getElementById('hashDetail'); if (!matches.length) { detail.innerHTML = `0x${hex}
No known nodes`; return; } detail.innerHTML = `0x${hex} — ${matches.length} node${matches.length !== 1 ? 's' : ''}` + `
${matches.map(m => { const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) ? `(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})` : '(no coords)'; const role = m.role ? `${esc(m.role)} ` : ''; return `
${role}${esc(m.name || m.public_key.slice(0,12))} ${coords}
`; }).join('')}
`; } ); } else if (bytes === 2) { const twoByteCells = sizeData.two_byte_cells || {}; const twoByteCount = stats.using_this_size || 0; const uniqueTwoBytePrefixes = stats.unique_prefixes || 0; const twoCollisions = Object.values(twoByteCells).filter(v => v.collision_count > 0).length; renderHashMatrixPanel(el, hashStatCardsHtml(totalNodes, twoByteCount, '2-byte', 65536, uniqueTwoBytePrefixes, twoCollisions), (hex, cs) => { const info = twoByteCells[hex] || { group_nodes: [], max_collision: 0, collision_count: 0, two_byte_map: {} }; const nodeCount = (info.group_nodes || []).length; const maxCol = info.max_collision || 0; const overlapping = Object.values(info.two_byte_map || {}).filter(v => v.length > 1); const hasConfirmed = overlapping.some(ns => ns.filter(n => n.role === 'repeater').length >= 2); const hasPossible = !hasConfirmed && overlapping.some(ns => ns.length >= 2); const { cls, bg } = classifyHashCell(maxCol > 0 ? maxCol : nodeCount === 0 ? 0 : 1, hasConfirmed, hasPossible); const nodeLabel2 = m => esc(m.name||m.public_key.slice(0,8)) + (!m.role ? ' (?)' : ''); const tip = nodeCount === 0 ? hashTooltipHtml(`0x${hex}__`, 'No nodes in this group') : (info.collision_count || 0) === 0 ? hashTooltipHtml(`0x${hex}__`, `${nodeCount} node${nodeCount>1?'s':''} — no 2-byte collisions`) : hashTooltipHtml(`0x${hex}__`, hasConfirmed ? (info.collision_count||0) + ' collision' + ((info.collision_count||0)>1?'s':'') : 'Possible conflict', Object.entries(info.two_byte_map||{}).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`
${p} — ${ns.map(nodeLabel2).join(', ')}
`).join('')); return hashCellTd(hex, cs, cls, bg, nodeCount, tip, maxCol > 0 ? '700' : '400'); }, 420, [ {cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'No nodes in group'}, {cls: 'hash-cell-taken', text: 'Nodes present, no collision'}, {cls: 'hash-cell-possible', text: 'Possible conflict'}, {cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'} ], (td) => { const hex = td.dataset.hex.toUpperCase(); const info = twoByteCells[hex]; const detail = document.getElementById('hashDetail'); if (!info || !(info.group_nodes || []).length) { detail.innerHTML = ''; return; } const groupNodes = info.group_nodes || []; let dhtml = `0x${hex}__ — ${groupNodes.length} node${groupNodes.length !== 1 ? 's' : ''} in group`; if ((info.collision_count || 0) === 0) { dhtml += `
✅ No 2-byte collisions in this group
`; dhtml += `
${groupNodes.map(m => { const prefix = m.public_key.slice(0,4).toUpperCase(); return `
${prefix} ${esc(m.name || m.public_key.slice(0,12))}
`; }).join('')}
`; } else { dhtml += `
`; for (const [twoHex, nodes] of Object.entries(info.two_byte_map || {}).sort()) { const isCollision = nodes.length > 1; dhtml += `
`; dhtml += `${twoHex}${isCollision ? ' COLLISION' : ''} `; dhtml += nodes.map(m => `${esc(m.name || m.public_key.slice(0,12))}`).join(', '); dhtml += `
`; } dhtml += '
'; } detail.innerHTML = dhtml; } ); } } function renderCollisionsFromServer(sizeData, bytes) { const el = document.getElementById('collisionList'); if (!sizeData) { el.innerHTML = '
No data
'; return; } const collisions = sizeData.collisions || []; if (!collisions.length) { const cleanMsg = bytes === 3 ? '✅ No 3-byte prefix collisions detected — all repeaters have unique 3-byte prefixes.' : `✅ No ${bytes}-byte collisions detected`; el.innerHTML = `
${cleanMsg}
`; return; } const showAppearances = bytes < 3; const t50 = formatDistanceRound(50); const t200 = formatDistanceRound(200); el.innerHTML = ` ${showAppearances ? '' : ''} ${collisions.map(c => { let badge, tooltip; if (c.classification === 'local') { badge = `🏘️ Local`; tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision'; } else if (c.classification === 'regional') { badge = `⚡ Regional`; tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links'; } else if (c.classification === 'distant') { badge = `🌐 Distant`; tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix'; } else { badge = '❓ Unknown'; tooltip = 'Not enough coordinate data to classify'; } const nodes = c.nodes || []; const distStr = c.with_coords >= 2 ? formatDistanceRound(c.max_dist_km) : ''; return ` ${showAppearances ? `` : ''} `; }).join('')}
PrefixAppearancesMax Distance Assessment Colliding Nodes
${c.prefix}${(c.appearances || 0).toLocaleString()}${distStr} ${badge} ${nodes.map(m => { const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) ? ` (${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})` : ' (no coords)'; return `${esc(m.name || m.public_key.slice(0,12))}${loc}`; }).join('
')}
🏘️ Local <${t50}: true prefix collision, same mesh area   ⚡ Regional ${t50}–${t200}: edge of LoRa range, possible atmospheric propagation   🌐 Distant >${t200}: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
`; } async function renderSubpaths(el) { el.innerHTML = '
Analyzing route patterns…
'; try { const rq = RegionFilter.regionQueryString(); const bulk = await api('/analytics/subpaths-bulk?groups=2-2:50,3-3:30,4-4:20,5-8:15' + rq, { ttl: CLIENT_TTL.analyticsRF }); const [d2, d3, d4, d5] = bulk.results; function renderTable(data, title) { if (!data.subpaths.length) return `

${title}

No data
`; const maxCount = data.subpaths[0]?.count || 1; return `

${title}

From ${data.totalPaths.toLocaleString()} paths with 2+ hops

${data.subpaths.map((s, i) => { const barW = Math.max(2, Math.round(s.count / maxCount * 100)); const hops = s.path.split(' → '); const rawHops = s.rawHops || []; const hasSelfLoop = hops.some((h, j) => j > 0 && h === hops[j - 1]); const routeDisplay = hops.map(h => esc(h)).join(' → '); const prefixDisplay = rawHops.join(' → '); return ``; }).join('')}
#RouteOccurrences% of pathsFrequency
${i + 1} ${routeDisplay}${hasSelfLoop ? ' 🔄' : ''}
${esc(prefixDisplay)}
${s.count.toLocaleString()} ${s.pct}%
`; } el.innerHTML = `

🛤️ Route Pattern Analysis

Click a route to see details. Most common subpaths — reveals backbone routes, bottlenecks, and preferred relay chains.

${renderTable(d2, 'Pairs (2-hop links)')}
${renderTable(d3, 'Triples (3-hop chains)')}
${renderTable(d4, 'Quads (4-hop chains)')}
${renderTable(d5, 'Long chains (5+ hops)')}
`; // Click handler for rows el.addEventListener('click', e => { const tr = e.target.closest('tr[data-hops]'); if (!tr) return; el.querySelectorAll('tr.subpath-selected').forEach(r => r.classList.remove('subpath-selected')); tr.classList.add('subpath-selected'); loadSubpathDetail(tr.dataset.hops); }); // Jump nav — scroll within list panel el.querySelectorAll('.subpath-jump-nav a').forEach(a => { a.addEventListener('click', e => { e.preventDefault(); const target = document.getElementById(a.getAttribute('href').slice(1)); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }); // Collision toggle const toggle = document.getElementById('hideCollisions'); function applyCollisionFilter() { const hide = toggle.checked; localStorage.setItem('subpath-hide-collisions', hide ? '1' : '0'); el.querySelectorAll('tr.subpath-selfloop').forEach(r => r.style.display = hide ? 'none' : ''); } toggle.addEventListener('change', applyCollisionFilter); applyCollisionFilter(); } catch (e) { el.innerHTML = `
Error loading subpath data: ${e.message}
`; } } async function loadSubpathDetail(hopsStr) { const panel = document.getElementById('subpathDetail'); panel.classList.remove('collapsed'); panel.innerHTML = '
Loading…
'; try { const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: CLIENT_TTL.analyticsRF }); renderSubpathDetail(panel, data); } catch (e) { panel.innerHTML = `
Error: ${e.message}
`; } } function renderSubpathDetail(panel, data) { const nodesWithLoc = data.nodes.filter(n => n.lat && n.lon && !(n.lat === 0 && n.lon === 0)); const hasMap = nodesWithLoc.length >= 2; const maxHour = Math.max(...data.hourDistribution, 1); panel.innerHTML = `

${data.nodes.map(n => esc(n.name)).join(' → ')}

${data.hops.join(' → ')} ${data.totalMatches.toLocaleString()} occurrences
${nodesWithLoc.length >= 2 ? `
📏 Hop Distances
${(() => { const dists = []; let total = 0; for (let i = 0; i < data.nodes.length - 1; i++) { const a = data.nodes[i], b = data.nodes[i+1]; if (a.lat && a.lon && b.lat && b.lon && !(a.lat===0&&a.lon===0) && !(b.lat===0&&b.lon===0)) { const km = window.HopResolver && window.HopResolver.haversineKm ? window.HopResolver.haversineKm(a.lat, a.lon, b.lat, b.lon) : (() => { const R=6371, dLat=(b.lat-a.lat)*Math.PI/180, dLon=(b.lon-a.lon)*Math.PI/180, h=Math.sin(dLat/2)**2+Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dLon/2)**2; return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h)); })(); total += km; const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)'; dists.push(`
${formatDistance(km)} ${esc(a.name)} → ${esc(b.name)}
`); } else { dists.push(`
? ${esc(a.name)} → ${esc(b.name)} (no coords)
`); } } if (dists.length > 1) dists.push(`
Total: ${formatDistance(total)}
`); return dists.join(''); })()}
` : ''} ${hasMap ? '
' : ''}
📡 Observer Receive Signal

Last hop → observer only, not between nodes in the route

${data.signal.avgSnr != null ? `
Avg SNR: ${data.signal.avgSnr} dB · Avg RSSI: ${data.signal.avgRssi} dBm · ${data.signal.samples} samples
` : '
No signal data
'}
🕐 Activity by Hour (UTC)
${data.hourDistribution.map((c, h) => `
`).join('')}
06121823
⏱️ Timeline
First seen: ${data.firstSeen ? (typeof formatAbsoluteTimestamp === 'function' ? formatAbsoluteTimestamp(data.firstSeen) : new Date(data.firstSeen).toLocaleString()) : '—'}
Last seen: ${data.lastSeen ? (typeof formatAbsoluteTimestamp === 'function' ? formatAbsoluteTimestamp(data.lastSeen) : new Date(data.lastSeen).toLocaleString()) : '—'}
${data.observers.length ? `
👁️ Observers
${data.observers.map(o => `
${esc(o.name)}: ${o.count}
`).join('')}
` : ''} ${data.parentPaths.length ? `
🔗 Full Paths Containing This Route
${data.parentPaths.map(p => `
${esc(p.path)} ×${p.count}
`).join('')}
` : ''}
`; // Render minimap if (hasMap && typeof L !== 'undefined') { const map = L.map('subpathMap', { zoomControl: false, attributionControl: false }); L.tileLayer(getTileUrl(), { maxZoom: 18 }).addTo(map); const latlngs = []; nodesWithLoc.forEach((n, i) => { const ll = [n.lat, n.lon]; latlngs.push(ll); const isEnd = i === 0 || i === nodesWithLoc.length - 1; L.circleMarker(ll, { radius: isEnd ? 8 : 5, color: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(), fillColor: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(), fillOpacity: 0.9, weight: 2 }).bindTooltip(n.name, { permanent: false }).addTo(map); }); L.polyline(latlngs, { color: statusYellow(), weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map); map.fitBounds(L.latLngBounds(latlngs).pad(0.3)); } } async function renderNodesTab(el) { el.innerHTML = '
Loading node analytics…
'; try { const rq = RegionFilter.regionQueryString(); const [nodesResp, bulkHealth] = await Promise.all([ api('/nodes?limit=10000&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }), api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }) ]); const nodes = nodesResp.nodes || nodesResp; const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]'); const myKeys = new Set(myNodes.map(n => n.pubkey)); // Map bulk health by pubkey const healthMap = {}; bulkHealth.forEach(h => { healthMap[h.public_key] = h; }); const enriched = nodes.filter(n => healthMap[n.public_key]).map(n => ({ ...n, health: { stats: healthMap[n.public_key].stats, observers: healthMap[n.public_key].observers } })); // Compute rankings const byPackets = [...enriched].sort((a, b) => (b.health.stats.totalTransmissions || b.health.stats.totalPackets || 0) - (a.health.stats.totalTransmissions || a.health.stats.totalPackets || 0)); const bySnr = [...enriched].filter(n => n.health.stats.avgSnr != null).sort((a, b) => b.health.stats.avgSnr - a.health.stats.avgSnr); const byObservers = [...enriched].sort((a, b) => (b.health.observers?.length || 0) - (a.health.observers?.length || 0)); const byRecent = [...enriched].filter(n => n.health.stats.lastHeard).sort((a, b) => new Date(b.health.stats.lastHeard) - new Date(a.health.stats.lastHeard)); // Compute network status client-side from loaded nodes using shared getHealthThresholds() const now = Date.now(); let active = 0, degraded = 0, silent = 0; nodes.forEach(function(n) { const role = n.role || 'unknown'; const th = getHealthThresholds(role); const lastMs = n.last_heard ? new Date(n.last_heard).getTime() : n.last_seen ? new Date(n.last_seen).getTime() : 0; const age = lastMs ? (now - lastMs) : Infinity; if (age < th.degradedMs) active++; else if (age < th.silentMs) degraded++; else silent++; }); const totalNodes = nodesResp.total || nodes.length; const roleCounts = nodesResp.counts || {}; function nodeLink(n) { return `${esc(n.name || n.public_key.slice(0, 12))}`; } function claimedBadge(n) { return myKeys.has(n.public_key) ? ' ★ MINE' : ''; } // ROLE_COLORS from shared roles.js el.innerHTML = `

🔍 Network Status

${active}
🟢 Active
${degraded}
🟡 Degraded
${silent}
🔴 Silent
${totalNodes}
Total Nodes

📊 Role Breakdown

${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => { const c = ROLE_COLORS[role] || '#6b7280'; return `${role}: ${count}`; }).join('')}
${myKeys.size ? `

⭐ My Claimed Nodes

${enriched.filter(n => myKeys.has(n.public_key)).map(n => { const s = n.health.stats; return ``; }).join('') || ''}
NodeRolePacketsAvg SNRObserversLast Heard
${nodeLink(n)} ${n.role} ${s.totalTransmissions || s.totalPackets || 0} ${s.avgSnr != null ? s.avgSnr.toFixed(1) + ' dB' : '—'} ${n.health.observers?.length || 0} ${s.lastHeard ? timeAgo(s.lastHeard) : '—'}
No claimed nodes have health data
` : ''}

🏆 Most Active Nodes

${byPackets.slice(0, 15).map((n, i) => ``).join('')}
#NodeRoleTotal PacketsPackets TodayAnalytics
${i + 1} ${nodeLink(n)}${claimedBadge(n)} ${n.role} ${n.health.stats.totalTransmissions || n.health.stats.totalPackets || 0} ${n.health.stats.packetsToday || 0} 📊

📶 Best Signal Quality

${bySnr.slice(0, 15).map((n, i) => ``).join('')}
#NodeRoleAvg SNRObserversAnalytics
${i + 1} ${nodeLink(n)}${claimedBadge(n)} ${n.role} ${n.health.stats.avgSnr.toFixed(1)} dB ${n.health.observers?.length || 0} 📊

👀 Most Observed Nodes

${byObservers.slice(0, 15).map((n, i) => ``).join('')}
#NodeRoleObserversAvg SNRAnalytics
${i + 1} ${nodeLink(n)}${claimedBadge(n)} ${n.role} ${n.health.observers?.length || 0} ${n.health.stats.avgSnr != null ? n.health.stats.avgSnr.toFixed(1) + ' dB' : '—'} 📊

⏰ Recently Active

${byRecent.slice(0, 15).map(n => ``).join('')}
NodeRoleLast HeardPackets TodayAnalytics
${nodeLink(n)}${claimedBadge(n)} ${n.role} ${timeAgo(n.health.stats.lastHeard)} ${n.health.stats.packetsToday || 0} 📊
`; } catch (e) { el.innerHTML = `
Failed to load node analytics: ${esc(e.message)}
`; } } async function renderDistanceTab(el) { try { const rqs = RegionFilter.regionQueryString(); const sep = rqs ? '?' + rqs.slice(1) : ''; const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF }); const s = data.summary; let html = `
${s.totalHops.toLocaleString()}
Total Hops Analyzed
${s.totalPaths.toLocaleString()}
Paths Analyzed
${formatDistance(s.avgDist)}
Avg Hop Distance
${formatDistance(s.maxDist)}
Max Hop Distance
`; // Category stats const cats = data.catStats; const distUnitLabel = getDistanceUnit() === 'mi' ? 'mi' : 'km'; html += `

Distance by Link Type

`; for (const [cat, st] of Object.entries(cats)) { if (!st.count) continue; html += ``; } html += `
TypeCountAvg (${distUnitLabel})Median (${distUnitLabel})Min (${distUnitLabel})Max (${distUnitLabel})
${esc(cat)}${st.count.toLocaleString()}${formatDistance(st.avg)}${formatDistance(st.median)}${formatDistance(st.min)}${formatDistance(st.max)}
`; // Histogram if (data.distHistogram && data.distHistogram.bins) { const buckets = data.distHistogram.bins.map(b => b.count); const labels = data.distHistogram.bins.map(b => b.x.toFixed(1)); html += `

Hop Distance Distribution

${barChart(buckets, labels, statusGreen())}
`; } // Distance over time if (data.distOverTime && data.distOverTime.length > 1) { html += `

Average Distance Over Time

${sparkSvg(data.distOverTime.map(d => d.avg), 'var(--accent)', 800, 120)}
`; } // Top hops leaderboard html += `

🏆 Top 20 Longest Hops

`; const top20 = data.topHops.slice(0, 20); top20.forEach((h, i) => { const fromLink = h.fromPk ? `${esc(h.fromName)}` : esc(h.fromName || '?'); const toLink = h.toPk ? `${esc(h.toName)}` : esc(h.toName || '?'); const bestSnr = h.bestSnr != null ? Number(h.bestSnr).toFixed(1) + ' dB' : ''; const medianSnr = h.medianSnr != null ? Number(h.medianSnr).toFixed(1) + ' dB' : ''; const obs = h.obsCount != null ? h.obsCount : 1; const pktLink = h.hash ? `${esc(h.hash.slice(0, 12))}…` : '—'; const mapBtn = h.fromPk && h.toPk ? `` : ''; const tsTitle = h.timestamp ? `Best observation: ${h.timestamp}` : ''; html += ``; }); html += `
#FromToDistance (${distUnitLabel})TypeObsBest SNRMedian SNRPacket
${i+1}${fromLink}${toLink}${formatDistance(h.dist)}${esc(h.type)}${obs}${bestSnr}${medianSnr}${pktLink}${mapBtn}
`; // Top paths if (data.topPaths.length) { html += `

🛤️ Top 10 Longest Multi-Hop Paths

`; data.topPaths.slice(0, 10).forEach((p, i) => { const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → '); const pktLink = p.hash ? `${esc(p.hash.slice(0, 12))}…` : '—'; // Collect all unique pubkeys in path order const pathPks = []; p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); }); if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); } const mapBtn = pathPks.length >= 2 ? `` : ''; html += ``; }); html += `
#Total Distance (${distUnitLabel})HopsRoutePacket
${i+1}${formatDistance(p.totalDist)}${p.hopCount}${route}${pktLink}${mapBtn}
`; } el.innerHTML = html; // Wire up map buttons el.querySelectorAll('.dist-map-hop').forEach(btn => { btn.addEventListener('click', () => { sessionStorage.setItem('map-route-hops', JSON.stringify({ hops: [btn.dataset.from, btn.dataset.to] })); window.location.hash = '#/map?route=1'; }); }); el.querySelectorAll('.dist-map-path').forEach(btn => { btn.addEventListener('click', () => { try { const hops = JSON.parse(btn.dataset.hops); sessionStorage.setItem('map-route-hops', JSON.stringify({ hops })); window.location.hash = '#/map?route=1'; } catch {} }); }); } catch (e) { el.innerHTML = `
Failed to load distance analytics: ${esc(e.message)}
`; } } function destroy() { _stopRolesRefresh(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } } // Expose for testing if (typeof window !== 'undefined') { window._analyticsDecorateChannels = decorateAnalyticsChannels; window._analyticsSortChannels = sortChannels; window._analyticsLoadChannelSort = loadChannelSort; window._analyticsSaveChannelSort = saveChannelSort; window._analyticsChannelTbodyHtml = channelTbodyHtml; window._analyticsChannelTheadHtml = channelTheadHtml; window._analyticsRfNFColumnChart = rfNFColumnChart; window._analyticsRenderMultiByteCapability = renderMultiByteCapability; window._analyticsRenderMultiByteAdopters = renderMultiByteAdopters; window._analyticsHashStatCardsHtml = hashStatCardsHtml; window._analyticsRenderCollisionsFromServer = renderCollisionsFromServer; } // ─── Neighbor Graph Tab ───────────────────────────────────────────────────── let _ngState = null; // neighbor graph state async function renderNeighborGraphTab(el) { el.innerHTML = `

🕸️ Neighbor Graph

📋 Text-based neighbor list (accessible alternative)
`; // Role checkboxes const roles = ['repeater','companion','room','sensor']; const rcEl = document.getElementById('ngRoleChecks'); roles.forEach(r => { const color = (window.ROLE_COLORS || {})[r] || '#888'; rcEl.innerHTML += ``; }); // Observer checkbox — unchecked by default (observers create hub-and-spoke noise) { const color = (window.ROLE_COLORS || {}).observer || '#8b5cf6'; rcEl.innerHTML += ``; } // Load data const rqs = RegionFilter.regionQueryString(); const sep = rqs ? '?' + rqs.slice(1) : ''; let graphData; try { graphData = await api('/analytics/neighbor-graph' + sep + (sep ? '&' : '?') + 'min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF }); } catch (e) { el.innerHTML = `

Failed to load neighbor graph: ${esc(e.message)}

`; return; } _ngState = createGraphState(graphData); renderNGStats(_ngState); startGraphRenderer(); // Filter listeners // Restore saved min score from localStorage var savedScore = localStorage.getItem('ng-min-score'); if (savedScore !== null) { document.getElementById('ngMinScore').value = savedScore; document.getElementById('ngMinScoreVal').textContent = (savedScore / 100).toFixed(2); applyNGFilters(); } document.getElementById('ngMinScore').addEventListener('input', function() { document.getElementById('ngMinScoreVal').textContent = (this.value / 100).toFixed(2); localStorage.setItem('ng-min-score', this.value); applyNGFilters(); }); document.getElementById('ngConfidence').addEventListener('change', applyNGFilters); rcEl.addEventListener('change', applyNGFilters); } function createGraphState(data) { const nodes = (data.nodes || []).map((n, i) => ({ ...n, x: 450 + (Math.random() - 0.5) * 400, y: 300 + (Math.random() - 0.5) * 300, vx: 0, vy: 0, radius: Math.max(6, Math.min(18, 6 + (n.neighbor_count || 0))) })); const nodeIdx = {}; nodes.forEach((n, i) => { nodeIdx[n.pubkey] = i; }); const edges = (data.edges || []).filter(e => nodeIdx[e.source] !== undefined && nodeIdx[e.target] !== undefined); return { allNodes: nodes, allEdges: edges, nodes, edges, nodeIdx, stats: data.stats || {}, zoom: 1, panX: 0, panY: 0, dragging: null, panning: false, lastMouseX: 0, lastMouseY: 0, cooling: 1.0, animId: null }; } function applyNGFilters() { if (!_ngState) return; const minScore = parseInt(document.getElementById('ngMinScore').value, 10) / 100; const conf = document.getElementById('ngConfidence').value; const checkedRoles = new Set(); document.querySelectorAll('#ngRoleChecks input:checked').forEach(cb => checkedRoles.add(cb.dataset.role)); // Filter nodes by role const visibleNodes = _ngState.allNodes.filter(n => { const role = (n.role || 'unknown').toLowerCase(); return checkedRoles.has(role) || role === 'unknown'; }); const visiblePKs = new Set(visibleNodes.map(n => n.pubkey)); // Filter edges _ngState.edges = _ngState.allEdges.filter(e => { if (e.score < minScore) return false; if (conf === 'high' && (e.ambiguous || e.score < 0.5)) return false; if (conf === 'hide-ambiguous' && e.ambiguous) return false; return visiblePKs.has(e.source) && visiblePKs.has(e.target); }); // Only include nodes that have at least one visible edge const edgeNodes = new Set(); _ngState.edges.forEach(e => { edgeNodes.add(e.source); edgeNodes.add(e.target); }); _ngState.nodes = visibleNodes.filter(n => edgeNodes.has(n.pubkey)); // Rebuild index _ngState.nodeIdx = {}; _ngState.nodes.forEach((n, i) => { _ngState.nodeIdx[n.pubkey] = i; }); _ngState.cooling = 1.0; renderNGStats(_ngState); } function renderNGStats(st) { const nodes = st.nodes, edges = st.edges; const totalScore = edges.reduce((s, e) => s + e.score, 0); const avgScore = edges.length ? (totalScore / edges.length) : 0; const ambiguous = edges.filter(e => e.ambiguous).length; const resolved = edges.length ? ((edges.length - ambiguous) / edges.length * 100) : 0; const statsEl = document.getElementById('ngStats'); if (!statsEl) return; statsEl.innerHTML = `
${nodes.length}
Nodes
${edges.length}
Edges
${avgScore.toFixed(2)}
Avg Score
${resolved.toFixed(0)}%
Resolved
${ambiguous}
Ambiguous
`; // Update canvas aria-label with current graph summary var canvas = document.getElementById('ngCanvas'); if (canvas) { canvas.setAttribute('aria-label', 'Neighbor affinity graph: ' + nodes.length + ' nodes, ' + edges.length + ' edges, ' + resolved.toFixed(0) + '% resolved. Use arrow keys to pan, +/- to zoom, 0 to reset.'); } // Update accessible text list updateNGTextList(st); } function updateNGTextList(st) { var listEl = document.getElementById('ngTextList'); if (!listEl) return; var nodes = st.nodes, edges = st.edges; if (nodes.length === 0) { listEl.innerHTML = '

No nodes to display.

'; return; } // Build adjacency for text list var adj = {}; edges.forEach(function(e) { if (!adj[e.source]) adj[e.source] = []; if (!adj[e.target]) adj[e.target] = []; adj[e.source].push({ pk: e.target, score: e.score, ambiguous: e.ambiguous }); adj[e.target].push({ pk: e.source, score: e.score, ambiguous: e.ambiguous }); }); var nodeMap = {}; nodes.forEach(function(n) { nodeMap[n.pubkey] = n; }); var html = ''; nodes.slice().sort(function(a, b) { return (a.name || a.pubkey).localeCompare(b.name || b.pubkey); }).forEach(function(n) { var neighbors = (adj[n.pubkey] || []).map(function(nb) { var peer = nodeMap[nb.pk]; var name = peer ? (peer.name || nb.pk.slice(0, 8)) : nb.pk.slice(0, 8); var conf = nb.ambiguous ? ' ⚠' : (nb.score >= 0.5 ? ' ●' : ' ○'); return esc(name) + conf; }).join(', '); html += ''; }); html += '
NodeRoleNeighbors
' + esc(n.name || n.pubkey.slice(0, 12)) + '' + esc(n.role || 'unknown') + '' + (neighbors || 'none') + '
'; html += '

● = high confidence (score ≥ 0.5), ○ = low confidence, ⚠ = ambiguous/unresolved

'; listEl.innerHTML = html; } function startGraphRenderer() { if (!_ngState) return; // Node count guard: skip force simulation for very large graphs var NODE_LIMIT = 1000; if (_ngState.allNodes.length > NODE_LIMIT) { var el = document.getElementById('ngCanvas'); if (el) { el.style.display = 'none'; var msg = document.createElement('div'); msg.className = 'analytics-card'; msg.innerHTML = '

Graph has ' + _ngState.allNodes.length + ' nodes (limit: ' + NODE_LIMIT + '). Force simulation skipped for performance. Use filters to reduce the node count.

'; el.parentNode.insertBefore(msg, el); } return; } const canvas = document.getElementById('ngCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; ctx.scale(dpr, dpr); const W = canvas.clientWidth, H = canvas.clientHeight; // Interaction let hoverNode = null; function canvasToGraph(cx, cy) { return { x: (cx - _ngState.panX) / _ngState.zoom, y: (cy - _ngState.panY) / _ngState.zoom }; } function findNode(cx, cy) { const gp = canvasToGraph(cx, cy); for (let i = _ngState.nodes.length - 1; i >= 0; i--) { const n = _ngState.nodes[i]; const dx = gp.x - n.x, dy = gp.y - n.y; if (dx * dx + dy * dy <= n.radius * n.radius) return n; } return null; } canvas.addEventListener('mousedown', function(e) { const rect = canvas.getBoundingClientRect(); const cx = e.clientX - rect.left, cy = e.clientY - rect.top; const n = findNode(cx, cy); if (n) { _ngState.dragging = n; n._pinned = true; canvas.style.cursor = 'grabbing'; } else { _ngState.panning = true; canvas.style.cursor = 'grabbing'; } _ngState.lastMouseX = e.clientX; _ngState.lastMouseY = e.clientY; }); canvas.addEventListener('mousemove', function(e) { const rect = canvas.getBoundingClientRect(); const cx = e.clientX - rect.left, cy = e.clientY - rect.top; if (_ngState.dragging) { const dx = (e.clientX - _ngState.lastMouseX) / _ngState.zoom; const dy = (e.clientY - _ngState.lastMouseY) / _ngState.zoom; _ngState.dragging.x += dx; _ngState.dragging.y += dy; _ngState.lastMouseX = e.clientX; _ngState.lastMouseY = e.clientY; _ngState.cooling = Math.max(_ngState.cooling, 0.3); } else if (_ngState.panning) { _ngState.panX += e.clientX - _ngState.lastMouseX; _ngState.panY += e.clientY - _ngState.lastMouseY; _ngState.lastMouseX = e.clientX; _ngState.lastMouseY = e.clientY; } else { const n = findNode(cx, cy); if (n !== hoverNode) { hoverNode = n; canvas.style.cursor = n ? 'pointer' : 'grab'; const tip = document.getElementById('ngTooltip'); if (n && tip) { tip.style.display = 'block'; tip.style.left = (cx + 12) + 'px'; tip.style.top = (cy - 8) + 'px'; tip.innerHTML = `${esc(n.name || n.pubkey.slice(0, 12) + '…')}
Role: ${esc(n.role || 'unknown')}
Neighbors: ${n.neighbor_count || 0}`; } else if (tip) { tip.style.display = 'none'; } } else if (hoverNode) { const tip = document.getElementById('ngTooltip'); if (tip) { tip.style.left = (cx + 12) + 'px'; tip.style.top = (cy - 8) + 'px'; } } } }); canvas.addEventListener('mouseup', function() { if (_ngState.dragging) { _ngState.dragging._pinned = false; _ngState._wasDragging = true; } _ngState.dragging = null; _ngState.panning = false; canvas.style.cursor = hoverNode ? 'pointer' : 'grab'; }); canvas.addEventListener('mouseleave', function() { _ngState.dragging = null; _ngState.panning = false; _ngState._wasDragging = false; const tip = document.getElementById('ngTooltip'); if (tip) tip.style.display = 'none'; hoverNode = null; }); canvas.addEventListener('click', function(e) { if (_ngState._wasDragging) { _ngState._wasDragging = false; return; } if (_ngState.dragging) return; const rect = canvas.getBoundingClientRect(); const n = findNode(e.clientX - rect.left, e.clientY - rect.top); if (n) location.hash = '#/nodes/' + n.pubkey; }); canvas.addEventListener('keydown', function(e) { const PAN_STEP = 30, ZOOM_STEP = 1.15; switch (e.key) { case 'ArrowLeft': _ngState.panX += PAN_STEP; e.preventDefault(); break; case 'ArrowRight': _ngState.panX -= PAN_STEP; e.preventDefault(); break; case 'ArrowUp': _ngState.panY += PAN_STEP; e.preventDefault(); break; case 'ArrowDown': _ngState.panY -= PAN_STEP; e.preventDefault(); break; case '+': case '=': _ngState.zoom = Math.min(10, _ngState.zoom * ZOOM_STEP); e.preventDefault(); break; case '-': case '_': _ngState.zoom = Math.max(0.1, _ngState.zoom / ZOOM_STEP); e.preventDefault(); break; case '0': _ngState.zoom = 1; _ngState.panX = 0; _ngState.panY = 0; e.preventDefault(); break; } }); canvas.addEventListener('wheel', function(e) { e.preventDefault(); const rect = canvas.getBoundingClientRect(); const cx = e.clientX - rect.left, cy = e.clientY - rect.top; const factor = e.deltaY < 0 ? 1.1 : 0.9; const newZoom = Math.max(0.1, Math.min(10, _ngState.zoom * factor)); // Zoom towards mouse position _ngState.panX = cx - (cx - _ngState.panX) * (newZoom / _ngState.zoom); _ngState.panY = cy - (cy - _ngState.panY) * (newZoom / _ngState.zoom); _ngState.zoom = newZoom; }, { passive: false }); // Cache text color to avoid getComputedStyle every frame const _labelColor = cssVar('--text-primary') || '#e0e0e0'; // Force simulation + render loop // Performance: 500 nodes brute-force repulsion: avg ~4ms/frame = 250fps headroom (measured Chrome 120, M1) var _perfFrameTimes = [], _perfLastTime = 0; function tick() { if (!document.getElementById('ngCanvas')) { _ngState.animId = null; return; } var now = performance.now(); if (_perfLastTime) _perfFrameTimes.push(now - _perfLastTime); _perfLastTime = now; if (_perfFrameTimes.length === 100) { var avg = _perfFrameTimes.reduce(function(a, b) { return a + b; }, 0) / 100; console.log('[NeighborGraph perf] avg frame time over 100 frames: ' + avg.toFixed(2) + 'ms (' + (1000 / avg).toFixed(0) + ' fps)'); _perfFrameTimes = []; } const st = _ngState; const nodes = st.nodes, edges = st.edges, idx = st.nodeIdx; if (st.cooling > 0.001) { // Repulsion (all pairs — use grid for large sets, brute force for small) const k = 80; // repulsion constant for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { let dx = nodes[j].x - nodes[i].x; let dy = nodes[j].y - nodes[i].y; let d2 = dx * dx + dy * dy; if (d2 < 1) { dx = Math.random() - 0.5; dy = Math.random() - 0.5; d2 = 1; } const f = k * k / d2; const fx = dx / Math.sqrt(d2) * f; const fy = dy / Math.sqrt(d2) * f; nodes[i].vx -= fx; nodes[i].vy -= fy; nodes[j].vx += fx; nodes[j].vy += fy; } } // Attraction along edges const idealLen = 120; for (const e of edges) { const si = idx[e.source], ti = idx[e.target]; if (si === undefined || ti === undefined) continue; const a = nodes[si], b = nodes[ti]; let dx = b.x - a.x, dy = b.y - a.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const f = (d - idealLen) * 0.05 * (0.5 + e.score * 0.5); const fx = dx / d * f, fy = dy / d * f; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } // Center gravity for (const n of nodes) { n.vx += (W / 2 - n.x) * 0.001; n.vy += (H / 2 - n.y) * 0.001; } // Apply velocities with damping const damping = 0.85; for (const n of nodes) { if (n._pinned) { n.vx = 0; n.vy = 0; continue; } n.vx *= damping * st.cooling; n.vy *= damping * st.cooling; const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy); if (speed > 10) { n.vx *= 10 / speed; n.vy *= 10 / speed; } n.x += n.vx; n.y += n.vy; } st.cooling *= 0.995; } // Render ctx.save(); ctx.clearRect(0, 0, W, H); ctx.translate(st.panX, st.panY); ctx.scale(st.zoom, st.zoom); // Edges for (const e of edges) { const si = idx[e.source], ti = idx[e.target]; if (si === undefined || ti === undefined) continue; const a = nodes[si], b = nodes[ti]; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.strokeStyle = e.ambiguous ? 'rgba(255,200,0,0.4)' : 'rgba(150,150,150,0.35)'; ctx.lineWidth = Math.max(0.5, e.score * 4); if (e.ambiguous) { ctx.setLineDash([4, 4]); } else { ctx.setLineDash([]); } ctx.stroke(); ctx.setLineDash([]); } // Nodes const roleColors = window.ROLE_COLORS || {}; for (const n of nodes) { const color = roleColors[(n.role || '').toLowerCase()] || '#6b7280'; ctx.beginPath(); ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); if (n === hoverNode) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); } // Label const label = n.name || (n.pubkey ? n.pubkey.slice(0, 8) + '…' : ''); if (label && st.zoom > 0.4) { ctx.fillStyle = _labelColor; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(label, n.x, n.y + n.radius + 12); } } ctx.restore(); st.animId = requestAnimationFrame(tick); } _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 allNodes = [...nodeMap.values()]; // Only repeaters matter for prefix collisions — they relay packets using hash prefixes. // Companions, rooms, and sensors don't route, so their prefix collisions are harmless. const nodes = allNodes.filter(n => n.role === 'repeater'); if (nodes.length === 0) { el.innerHTML = `

No repeaters 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} repeaters, 1-byte prefixes have low collision risk.`; } else if (totalNodes < 500) { rec = '2-byte'; recDetail = `With ${totalNodes} repeaters, 2-byte prefixes are recommended to avoid collisions.`; } else { rec = '2-byte'; recDetail = `With ${totalNodes} repeaters, 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 repeaters →

` : ''; 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 hs = n.hash_size ? ` ${n.hash_size}B hash` : ''; const when = n.last_seen ? ` ${(typeof formatAbsoluteTimestamp === 'function') ? formatAbsoluteTimestamp(n.last_seen) : new Date(n.last_seen).toLocaleDateString()}` : ''; return `
${name} ${role}${hs}${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); } } // ===================== RF HEALTH ===================== let _rfHealthState = { range: '24h', selectedObserver: null, customFrom: '', customTo: '' }; function rfHealthTimeRangeToParams(range, customFrom, customTo) { const now = new Date(); let since, until; if (range === 'custom' && customFrom) { since = new Date(customFrom).toISOString(); until = customTo ? new Date(customTo).toISOString() : now.toISOString(); } else { const durations = { '1h': 1, '3h': 3, '6h': 6, '12h': 12, '24h': 24, '3d': 72, '7d': 168, '30d': 720 }; const hours = durations[range] || 24; since = new Date(now.getTime() - hours * 3600000).toISOString(); until = now.toISOString(); } return { since, until }; } function rfHealthUpdateHash() { const params = new URLSearchParams(); params.set('tab', 'rf-health'); if (_rfHealthState.range !== '24h') params.set('range', _rfHealthState.range); if (_rfHealthState.selectedObserver) params.set('observer', _rfHealthState.selectedObserver); if (_rfHealthState.range === 'custom') { if (_rfHealthState.customFrom) params.set('from', _rfHealthState.customFrom); if (_rfHealthState.customTo) params.set('to', _rfHealthState.customTo); } history.replaceState(null, '', '#/analytics?' + params.toString()); } async function renderRFHealthTab(el) { // Restore state from URL const hashParams = new URLSearchParams((location.hash.split('?')[1] || '')); if (hashParams.get('range')) _rfHealthState.range = hashParams.get('range'); if (hashParams.get('observer')) _rfHealthState.selectedObserver = hashParams.get('observer'); if (hashParams.get('from')) { _rfHealthState.customFrom = hashParams.get('from'); _rfHealthState.range = 'custom'; } if (hashParams.get('to')) { _rfHealthState.customTo = hashParams.get('to'); _rfHealthState.range = 'custom'; } const ranges = ['1h','3h','6h','12h','24h','3d','7d','30d']; const rangeButtons = ranges.map(r => `` ).join(''); el.innerHTML = `
${rangeButtons}
Loading RF metrics…
Select an observer to view details
`; // Range button handlers el.querySelectorAll('.rf-range-btn[data-range]').forEach(btn => { btn.addEventListener('click', () => { const range = btn.dataset.range; _rfHealthState.range = range; el.querySelectorAll('.rf-range-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const customInputs = el.querySelector('.rf-custom-inputs'); if (customInputs) customInputs.style.display = range === 'custom' ? 'inline' : 'none'; if (range !== 'custom') { rfHealthUpdateHash(); loadRFHealthData(el); } }); }); const applyBtn = document.getElementById('rfCustomApply'); if (applyBtn) { applyBtn.addEventListener('click', () => { _rfHealthState.customFrom = document.getElementById('rfFrom').value; _rfHealthState.customTo = document.getElementById('rfTo').value; rfHealthUpdateHash(); loadRFHealthData(el); }); } await loadRFHealthData(el); } async function loadRFHealthData(el) { const grid = document.getElementById('rfHealthGrid'); const detail = document.getElementById('rfHealthDetail'); try { // Compute window string for summary endpoint const windowMap = { '1h':'1h', '3h':'3h', '6h':'6h', '12h':'12h', '24h':'24h', '3d':'3d', '7d':'7d', '30d':'30d' }; const window = windowMap[_rfHealthState.range] || '24h'; const summaryData = await api('/observers/metrics/summary?window=' + window + (RegionFilter.regionQueryString() || '')); const observers = summaryData.observers || []; // Filter to observers with sufficient sparkline data (≥2 non-null noise_floor values) const filteredObservers = observers.filter(obs => { const nfValues = (obs.sparkline || []).filter(v => v != null); return nfValues.length >= 2; }); if (!filteredObservers.length) { grid.innerHTML = '
No RF metrics data available yet. Metrics are collected from observer status messages every ~5 minutes.
'; return; } // Render small multiples grid grid.innerHTML = filteredObservers.map(obs => { const nf = obs.current_noise_floor != null ? obs.current_noise_floor.toFixed(1) : '—'; const avgNf = obs.avg_noise_floor_24h != null ? obs.avg_noise_floor_24h.toFixed(1) : '—'; const maxNf = obs.max_noise_floor_24h != null ? obs.max_noise_floor_24h.toFixed(1) : '—'; const batt = obs.battery_mv != null ? (obs.battery_mv / 1000).toFixed(2) + 'V' : ''; const name = obs.observer_name || obs.observer_id.substring(0, 8); const isSelected = _rfHealthState.selectedObserver === obs.observer_id; // NF status coloring let nfClass = ''; if (obs.current_noise_floor != null) { if (obs.current_noise_floor >= -85) nfClass = 'rf-nf-critical'; else if (obs.current_noise_floor >= -100) nfClass = 'rf-nf-warning'; } return `
${esc(name)} ${nf} dBm ${batt ? `${batt}` : ''}
avg: ${avgNf} max: ${maxNf} ${obs.sample_count} samples
`; }).join(''); // Click handler for cells grid.querySelectorAll('.rf-cell').forEach(cell => { cell.addEventListener('click', () => { const obsId = cell.dataset.observer; grid.querySelectorAll('.rf-cell').forEach(c => c.classList.remove('rf-cell-selected')); cell.classList.add('rf-cell-selected'); _rfHealthState.selectedObserver = obsId; rfHealthUpdateHash(); loadRFHealthDetail(obsId, detail); }); cell.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); cell.click(); } }); }); // Render sparklines from summary data (no extra API calls) for (const obs of filteredObservers) { const nfValues = (obs.sparkline || []).filter(v => v != null); const container = document.getElementById(`rf-spark-${obs.observer_id}`); if (container && nfValues.length > 1) { container.innerHTML = rfNFSparkline(nfValues, 140, 24); } } // Auto-expand selected observer from URL if (_rfHealthState.selectedObserver) { const selectedCell = grid.querySelector(`[data-observer="${_rfHealthState.selectedObserver}"]`); if (selectedCell) { selectedCell.classList.add('rf-cell-selected'); loadRFHealthDetail(_rfHealthState.selectedObserver, detail); } } } catch (e) { grid.innerHTML = `
Failed to load RF health data: ${esc(e.message)}
`; } } async function loadRFSparkline(observerId) { const { since, until } = rfHealthTimeRangeToParams(_rfHealthState.range, _rfHealthState.customFrom, _rfHealthState.customTo); try { const data = await api(`/observers/${observerId}/metrics?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}`); const metrics = data.metrics || []; const nfValues = metrics.map(m => m.noise_floor).filter(v => v != null); const container = document.getElementById(`rf-spark-${observerId}`); if (container && nfValues.length > 1) { container.innerHTML = rfNFSparkline(nfValues, 140, 24); } else if (container) { container.innerHTML = 'insufficient data'; } } catch (e) { // Non-fatal — sparkline just won't render } } function rfNFSparkline(data, w, h) { if (!data.length) return ''; // For noise floor, invert: more negative = better = lower on chart const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1; const pts = data.map((v, i) => { const x = (i / Math.max(data.length - 1, 1)) * w; // Higher dBm (worse) = higher on chart const y = h - 2 - ((v - min) / range) * (h - 4); return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); // Reference lines let refs = ''; if (min <= -100 && max >= -100) { const y100 = h - 2 - ((-100 - min) / range) * (h - 4); refs += ``; } return `Noise floor trend${refs}`; } async function loadRFHealthDetail(observerId, container) { container.classList.remove('rf-panel-empty'); container.innerHTML = '
Loading detail…
'; const { since, until } = rfHealthTimeRangeToParams(_rfHealthState.range, _rfHealthState.customFrom, _rfHealthState.customTo); // Choose resolution based on time range let resolution = '5m'; const rangeMap = { '7d': '1h', '30d': '1h' }; if (rangeMap[_rfHealthState.range]) resolution = rangeMap[_rfHealthState.range]; try { const data = await api(`/observers/${observerId}/metrics?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}&resolution=${resolution}`); const metrics = data.metrics || []; const reboots = (data.reboots || []).map(r => new Date(r).getTime()); const name = data.observer_name || observerId.substring(0, 8); if (!metrics.length) { container.innerHTML = `
No metrics data for ${esc(name)} in selected time range.
`; return; } // Extract data series const nfData = metrics.map(m => ({ t: m.timestamp, v: m.noise_floor })).filter(d => d.v != null); const txData = metrics.map(m => ({ t: m.timestamp, v: m.tx_airtime_pct })).filter(d => d.v != null); const rxData = metrics.map(m => ({ t: m.timestamp, v: m.rx_airtime_pct })).filter(d => d.v != null); const errData = metrics.map(m => ({ t: m.timestamp, v: m.recv_error_rate })).filter(d => d.v != null); const battData = metrics.map(m => ({ t: m.timestamp, v: m.battery_mv })).filter(d => d.v != null && d.v > 0); const hasAirtime = txData.length > 1 || rxData.length > 1; const hasErrors = errData.length > 1; const hasBattery = battData.length > 1; // Current values const latest = metrics[metrics.length - 1]; const nfValues = metrics.map(m => m.noise_floor).filter(v => v != null); const avgNf = nfValues.length ? (nfValues.reduce((a,b) => a+b, 0) / nfValues.length).toFixed(1) : '—'; const minNf = nfValues.length ? Math.min(...nfValues).toFixed(1) : '—'; const maxNf = nfValues.length ? Math.max(...nfValues).toFixed(1) : '—'; const curNf = latest.noise_floor != null ? latest.noise_floor.toFixed(1) : '—'; const curBatt = latest.battery_mv != null && latest.battery_mv > 0 ? (latest.battery_mv / 1000).toFixed(2) + 'V' : '—'; const curTx = latest.tx_airtime_pct != null ? latest.tx_airtime_pct.toFixed(1) + '%' : '—'; const curRx = latest.rx_airtime_pct != null ? latest.rx_airtime_pct.toFixed(1) + '%' : '—'; const curErr = latest.recv_error_rate != null ? latest.recv_error_rate.toFixed(2) + '%' : '—'; container.innerHTML = `

${esc(name)}

${hasAirtime ? '
' : ''} ${hasErrors ? '
' : ''} ${hasBattery ? '
' : ''}
NF: ${curNf} dBm | avg: ${avgNf} | min: ${minNf} | max: ${maxNf} | TX: ${curTx} | RX: ${curRx} | Err: ${curErr} | Batt: ${curBatt}${reboots.length ? ' | ' + reboots.length + ' reboots' : ''}
`; // Close button container.querySelector('.rf-detail-close').addEventListener('click', () => { container.classList.add('rf-panel-empty'); container.innerHTML = 'Select an observer to view details'; _rfHealthState.selectedObserver = null; rfHealthUpdateHash(); document.querySelectorAll('.rf-cell').forEach(c => c.classList.remove('rf-cell-selected')); }); // Compute shared time range across all charts const allTimestamps = metrics.map(m => new Date(m.timestamp).getTime()); const minT = Math.min(...allTimestamps); const maxT = Math.max(...allTimestamps); // Render noise floor chart const nfEl = document.getElementById('rfDetailNFChart'); if (nfEl && nfData.length > 1) { nfEl.innerHTML = rfNFColumnChart(nfData, nfEl.clientWidth || 700, 180, reboots, minT, maxT); } else if (nfEl) { nfEl.innerHTML = 'Not enough noise floor data'; } // Render airtime chart if (hasAirtime) { const atEl = document.getElementById('rfDetailAirtimeChart'); if (atEl) { atEl.innerHTML = rfAirtimeChart(txData, rxData, atEl.clientWidth || 700, 150, reboots, minT, maxT); } } // Render error rate chart if (hasErrors) { const errEl = document.getElementById('rfDetailErrorChart'); if (errEl) { errEl.innerHTML = rfErrorRateChart(errData, errEl.clientWidth || 700, 120, reboots, minT, maxT); } } // Render battery chart if (hasBattery) { const battEl = document.getElementById('rfDetailBatteryChart'); if (battEl) { battEl.innerHTML = rfBatteryChart(battData, battEl.clientWidth || 700, 120, reboots, minT, maxT); } } } catch (e) { container.innerHTML = `
Failed to load detail: ${esc(e.message)}
`; } } // Shared helper: render reboot markers as vertical hairlines function rfRebootMarkers(reboots, sx, pad, h, w) { let svg = ''; for (const rt of reboots) { const x = sx(rt); if (x >= pad.left && x <= w - pad.right) { svg += ``; svg += `reboot`; } } return svg; } // Shared helper: render X-axis time labels function rfTooltipCircles(data, sx, sy, label, unit, formatV) { let svg = ''; formatV = formatV || (v => v.toFixed(1)); data.forEach(d => { const t = new Date(d.t); const x = sx(t.getTime()); const y = sy(d.v); const ts = (typeof formatAbsoluteTimestamp === 'function') ? formatAbsoluteTimestamp(d.t) : t.toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC'); const tip = `${label}: ${formatV(d.v)}${unit}\n${ts}`; svg += `${tip}`; }); return svg; } function rfXAxisLabels(data, sx, h, pad) { let svg = ''; const xTicks = Math.min(6, data.length); for (let i = 0; i < xTicks; i++) { const idx = Math.floor(i * (data.length - 1) / Math.max(xTicks - 1, 1)); const t = new Date(data[idx].t); const x = sx(t.getTime()); const label = (typeof formatChartAxisLabel === 'function') ? formatChartAxisLabel(t, true) : t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); svg += `${label}`; } return svg; } // Shared: build polyline points string from data, skip nulls (break line) // Airtime chart: TX (red/orange) + RX (blue) lines, Y 0-100% function rfAirtimeChart(txData, rxData, w, h, reboots, sharedMinT, sharedMaxT) { const pad = { top: 20, right: 50, bottom: 30, left: 55 }; const cw = w - pad.left - pad.right; const ch = h - pad.top - pad.bottom; const minT = sharedMinT, maxT = sharedMaxT; const rangeT = maxT - minT || 1; // Auto-scale Y-axis to data range (20% headroom, min 1%) let dataMax = 0; for (let i = 0; i < txData.length; i++) { if (txData[i].v > dataMax) dataMax = txData[i].v; } for (let i = 0; i < rxData.length; i++) { if (rxData[i].v > dataMax) dataMax = rxData[i].v; } const yMax = Math.max(dataMax * 1.2, 1); const sx = t => pad.left + ((t - minT) / rangeT) * cw; const sy = v => pad.top + ch - (v / yMax) * ch; let svg = `Airtime %`; // Chart title svg += `Airtime %`; // Y-axis: 5 ticks from 0 to yMax const yTicks = 4; for (let i = 0; i <= yTicks; i++) { const v = yMax * i / yTicks; const y = sy(v); svg += `${v.toFixed(1)}`; svg += ``; } // Reboot markers svg += rfRebootMarkers(reboots, sx, pad, h, w); // TX line (red/orange) if (txData.length > 1) { const txPts = txData.map(d => `${sx(new Date(d.t).getTime()).toFixed(1)},${sy(d.v).toFixed(1)}`).join(' '); svg += ``; // Direct label at last point const lastTx = txData[txData.length - 1]; const lx = sx(new Date(lastTx.t).getTime()); const ly = sy(lastTx.v); // Offset label up if RX label would overlap (within 12px) const lastRx = rxData.length > 1 ? rxData[rxData.length - 1] : null; const rxLy = lastRx ? sy(lastRx.v) : Infinity; const txLabelY = (Math.abs(ly - rxLy) < 12) ? ly - 8 : ly + 3; svg += `TX ${lastTx.v.toFixed(1)}%`; } // RX line (blue) if (rxData.length > 1) { const rxPts = rxData.map(d => `${sx(new Date(d.t).getTime()).toFixed(1)},${sy(d.v).toFixed(1)}`).join(' '); svg += ``; // Direct label at last point const lastRx = rxData[rxData.length - 1]; const lx = sx(new Date(lastRx.t).getTime()); const ly = sy(lastRx.v); // Offset label down if TX label is nearby const lastTx = txData.length > 1 ? txData[txData.length - 1] : null; const txLy = lastTx ? sy(lastTx.v) : -Infinity; const rxLabelY = (Math.abs(ly - txLy) < 12) ? ly + 12 : ly + 3; svg += `RX ${lastRx.v.toFixed(1)}%`; } // X-axis labels const allData = txData.length >= rxData.length ? txData : rxData; svg += rfXAxisLabels(allData, sx, h, pad); // Hover tooltips svg += rfTooltipCircles(txData, sx, sy, 'TX', '%'); svg += rfTooltipCircles(rxData, sx, sy, 'RX', '%'); svg += ''; return svg; } // Error rate chart: recv_error_rate line function rfErrorRateChart(errData, w, h, reboots, sharedMinT, sharedMaxT) { const pad = { top: 20, right: 50, bottom: 30, left: 55 }; const cw = w - pad.left - pad.right; const ch = h - pad.top - pad.bottom; const minT = sharedMinT, maxT = sharedMaxT; const rangeT = maxT - minT || 1; const values = errData.map(d => d.v); const maxV = Math.max(...values, 1); // at least 1% scale const rangeV = maxV || 1; const sx = t => pad.left + ((t - minT) / rangeT) * cw; const sy = v => pad.top + ch - (v / rangeV) * ch; let svg = `Error Rate`; // Chart title svg += `Error Rate %`; // Y-axis const yTicks = 4; for (let i = 0; i <= yTicks; i++) { const v = (rangeV * i / yTicks); const y = sy(v); svg += `${v.toFixed(1)}`; svg += ``; } // Reboot markers svg += rfRebootMarkers(reboots, sx, pad, h, w); // Error rate line const pts = errData.map(d => `${sx(new Date(d.t).getTime()).toFixed(1)},${sy(d.v).toFixed(1)}`).join(' '); svg += ``; // Direct label at last point const last = errData[errData.length - 1]; const lx = sx(new Date(last.t).getTime()); const ly = sy(last.v); svg += `${last.v.toFixed(2)}%`; // X-axis labels svg += rfXAxisLabels(errData, sx, h, pad); // Hover tooltips svg += rfTooltipCircles(errData, sx, sy, 'Err', '%', v => v.toFixed(2)); svg += ''; return svg; } // Battery voltage chart function rfBatteryChart(battData, w, h, reboots, sharedMinT, sharedMaxT) { const pad = { top: 20, right: 50, bottom: 30, left: 55 }; const cw = w - pad.left - pad.right; const ch = h - pad.top - pad.bottom; const minT = sharedMinT, maxT = sharedMaxT; const rangeT = maxT - minT || 1; const values = battData.map(d => d.v); const minV = Math.min(...values); const maxV = Math.max(...values); const rangeV = maxV - minV || 100; // at least 100mV range const sx = t => pad.left + ((t - minT) / rangeT) * cw; const sy = v => pad.top + ch - ((v - minV) / rangeV) * ch; let svg = `Battery`; // Chart title svg += `Battery`; // Y-axis (in volts) const yTicks = 4; for (let i = 0; i <= yTicks; i++) { const v = minV + (rangeV * i / yTicks); const y = sy(v); svg += `${(v/1000).toFixed(2)}V`; svg += ``; } // Low battery reference line at 3.3V const lowBattMv = 3300; if (lowBattMv >= minV && lowBattMv <= maxV) { const y = sy(lowBattMv); svg += ``; svg += `3.3V low`; } // Reboot markers svg += rfRebootMarkers(reboots, sx, pad, h, w); // Battery line const pts = battData.map(d => `${sx(new Date(d.t).getTime()).toFixed(1)},${sy(d.v).toFixed(1)}`).join(' '); svg += ``; // Direct label at last point const last = battData[battData.length - 1]; const lx = sx(new Date(last.t).getTime()); const ly = sy(last.v); svg += `${(last.v/1000).toFixed(2)}V`; // X-axis labels svg += rfXAxisLabels(battData, sx, h, pad); // Hover tooltips svg += rfTooltipCircles(battData, sx, sy, 'Batt', 'V', v => (v/1000).toFixed(2)); svg += ''; return svg; } /** * Noise floor column chart — color-coded bars (green/yellow/red) by threshold. * Replaces the old line chart for better discrete-sample readability. * Thresholds: green (< -100 dBm), yellow (-100 to -85 dBm), red (≥ -85 dBm). */ function rfNFColumnChart(data, w, h, reboots, sharedMinT, sharedMaxT) { if (!data || !data.length) return ''; reboots = reboots || []; const pad = { top: 20, right: 40, bottom: 30, left: 55 }; const cw = w - pad.left - pad.right; const ch = h - pad.top - pad.bottom; const values = data.map(d => d.v); const minT = sharedMinT != null ? sharedMinT : Math.min(...data.map(d => new Date(d.t).getTime())); const maxT = sharedMaxT != null ? sharedMaxT : Math.max(...data.map(d => new Date(d.t).getTime())); const minV = Math.min(...values); const maxV = Math.max(...values); // Guard against zero range (single data point or constant values): // use a ±5 dBm window so bars are visible and centered in the chart const rawRangeV = maxV - minV; const rangeV = rawRangeV || 10; const adjMinV = rawRangeV ? minV : minV - 5; const rangeT = maxT - minT || 1; const sx = t => pad.left + ((t - minT) / rangeT) * cw; const sy = v => pad.top + ch - ((v - adjMinV) / rangeV) * ch; // Column width: proportional to chart width / data points, min 2px, gap of 1px const colW = Math.max(2, Math.floor(cw / data.length) - 1); const times = data.map(d => new Date(d.t).getTime()); let svg = `Noise floor over time`; // Inline style for hover highlighting svg += ``; // Chart title svg += `Noise Floor dBm`; // Y-axis labels + grid lines const yTicks = 5; for (let i = 0; i <= yTicks; i++) { const v = adjMinV + (rangeV * i / yTicks); const y = sy(v); svg += `${v.toFixed(0)}`; svg += ``; } // Reboot markers svg += rfRebootMarkers(reboots, sx, pad, h, w); // X-axis labels svg += rfXAxisLabels(data, sx, h, pad); // Color-coded columns for (let i = 0; i < data.length; i++) { const t = times[i]; const v = data[i].v; const x = sx(t) - colW / 2; const y = sy(v); const barH = pad.top + ch - y; // Threshold color: green < -100, yellow -100 to -85, red >= -85 let color; if (v < -100) color = 'var(--success, #22c55e)'; else if (v < -85) color = 'var(--warning, #eab308)'; else color = 'var(--danger, #ef4444)'; const ts = new Date(data[i].t).toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC'); const tip = `NF: ${v.toFixed(1)} dBm\n${ts}`; svg += `${tip}`; } // Y-axis label svg += `dBm`; // Legend const legendY = pad.top + 2; const legendX = w - pad.right - 140; svg += ``; svg += `< -100`; svg += ``; svg += `-100…-85`; svg += ``; svg += `≥ -85`; svg += ''; return svg; } // #690 — Clock Health fleet view (M3) async function renderClockHealthTab(el) { el.innerHTML = '
Loading clock health data…
'; try { var data = await (await fetch('/api/nodes/clock-skew')).json(); if (!Array.isArray(data) || !data.length) { el.innerHTML = '
No clock skew data available. Nodes need recent adverts for clock analysis.
'; return; } // State var activeFilter = 'all'; var sortKey = 'severity'; var sortDir = 'asc'; // severity worst-first function render() { // Filter var filtered = activeFilter === 'all' ? data : data.filter(function(n) { return n.severity === activeFilter; }); // Sort filtered = filtered.slice().sort(function(a, b) { var v; if (sortKey === 'severity') { v = (SKEW_SEVERITY_ORDER[a.severity] || 9) - (SKEW_SEVERITY_ORDER[b.severity] || 9); } else if (sortKey === 'skew') { v = Math.abs(window.currentSkewValue(b) || 0) - Math.abs(window.currentSkewValue(a) || 0); } else if (sortKey === 'name') { v = (a.nodeName || '').localeCompare(b.nodeName || ''); } else if (sortKey === 'drift') { v = Math.abs(b.driftPerDaySec || 0) - Math.abs(a.driftPerDaySec || 0); } return sortDir === 'desc' ? -v : v; }); // Summary var counts = { ok: 0, warning: 0, critical: 0, absurd: 0 }; data.forEach(function(n) { if (counts[n.severity] !== undefined) counts[n.severity]++; }); // Filter buttons (also serve as summary — no separate stats pills needed) var filterColors = { ok: 'var(--status-green)', warning: 'var(--status-yellow)', critical: 'var(--status-orange)', absurd: 'var(--status-purple)', no_clock: 'var(--text-muted)' }; var filters = ['all', 'ok', 'warning', 'critical', 'absurd', 'no_clock']; var filterHtml = '
' + filters.map(function(f) { var dot = f !== 'all' ? '' : ''; return ''; }).join('') + '
'; // Table var rowsHtml = filtered.map(function(n) { var rowClass = 'clock-fleet-row--' + (n.severity || 'ok'); var lastAdv = n.lastObservedTS ? new Date(n.lastObservedTS * 1000).toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC') : '—'; var skewVal = window.currentSkewValue(n); var skewText = n.severity === 'no_clock' ? 'No Clock' : formatSkew(skewVal); var driftText = n.severity === 'no_clock' || !n.driftPerDaySec ? '–' : formatDrift(n.driftPerDaySec); return '' + '' + esc(n.nodeName || n.pubkey.slice(0, 12)) + '' + '' + skewText + '' + '' + renderSkewBadge(n.severity, skewVal, n) + '' + '' + driftText + '' + '' + lastAdv + '' + ''; }).join(''); el.innerHTML = '

⏰ Clock Health

' + filterHtml + '' + '' + '' + '' + '' + '' + '' + '' + rowsHtml + '
NameSkewSeverityDrift RateLast Advert
'; // Bind filter clicks el.querySelectorAll('.clock-filter-btn').forEach(function(btn) { btn.addEventListener('click', function() { activeFilter = btn.dataset.filter; render(); }); }); // Bind header sort clicks el.querySelectorAll('[data-sort-col]').forEach(function(th) { th.addEventListener('click', function() { var col = th.dataset.sortCol; if (sortKey === col) { sortDir = sortDir === 'asc' ? 'desc' : 'asc'; } else { sortKey = col; sortDir = 'asc'; } render(); }); }); // Bind row clicks → navigate to node el.querySelectorAll('tr[data-pubkey]').forEach(function(tr) { tr.addEventListener('click', function() { location.hash = '#/nodes/' + encodeURIComponent(tr.dataset.pubkey); }); }); } render(); } catch (err) { el.innerHTML = '
Failed to load clock health data: ' + esc(String(err)) + '
'; } } // #1085 — Roles tab (folded in from former /#/roles page). // Renders distribution of node roles + per-role clock-skew posture. // Auto-refreshes every 60s while the Roles tab is active (matches the // behavior of the former standalone roles-page.js). async function renderRolesTab(el) { el.innerHTML = '
Loading roles…
'; await _renderRolesTabBody(el); // (Re)start the 60s auto-refresh. _stopRolesRefresh(); _rolesRefreshTimer = setInterval(function () { // Bail if the user navigated away from the Roles tab. if (_currentTab !== 'roles') { _stopRolesRefresh(); return; } var cur = document.getElementById('analyticsContent'); if (!cur) { _stopRolesRefresh(); return; } _renderRolesTabBody(cur); }, 60000); } async function _renderRolesTabBody(el) { try { var data = await api('/analytics/roles', { ttl: CLIENT_TTL.analyticsRF }); var roles = (data && data.roles) || []; var total = (data && data.totalNodes) || 0; if (!roles.length) { el.innerHTML = '
No roles to show.
'; return; } var maxCount = roles.reduce(function (m, r) { return Math.max(m, r.nodeCount || 0); }, 0) || 1; var rows = roles.map(function (r) { var pct = total > 0 ? ((r.nodeCount / total) * 100).toFixed(1) : '0.0'; var barW = Math.round((r.nodeCount / maxCount) * 100); var sevCells = '' + (r.okCount || 0) + ' / ' + '' + (r.warningCount || 0) + ' / ' + '' + (r.criticalCount || 0) + ' / ' + '' + (r.absurdCount || 0) + ' / ' + '' + (r.noClockCount || 0) + ''; return '' + '' + '' + _rolesEmoji(r.role) + ' ' + esc(r.role) + '' + '' + r.nodeCount + '' + '' + pct + '%' + '' + '
' + '
' + '
' + '' + '' + (r.withSkew || 0) + '' + '' + _rolesFmtSec(r.medianAbsSkewSec || 0) + '' + '' + _rolesFmtSec(r.meanAbsSkewSec || 0) + '' + '' + sevCells + '' + ''; }).join(''); el.innerHTML = '

Distribution of node roles across the mesh, with per-role clock-skew posture.

' + '
' + '' + total + ' nodes across ' + roles.length + ' roles' + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + rows + '' + '
RoleCountShareDistributionw/ SkewMedian |skew|Mean |skew|Severity
'; } catch (err) { el.innerHTML = '
Failed to load roles: ' + esc(String(err.message || err)) + '
'; } } registerPage('analytics', { init, destroy }); })();