/* === MeshCore Analyzer — nodes.js === */ 'use strict'; (function () { let nodes = []; const PAYLOAD_TYPES = {0:'Request',1:'Response',2:'Direct Msg',3:'ACK',4:'Advert',5:'Channel Msg',7:'Anon Req',8:'Path',9:'Trace'}; function syncClaimedToFavorites() { const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]'); const favs = getFavorites(); let changed = false; myNodes.forEach(mn => { if (!favs.includes(mn.pubkey)) { favs.push(mn.pubkey); changed = true; } }); if (changed) localStorage.setItem('meshcore-favorites', JSON.stringify(favs)); } let counts = {}; let selectedKey = null; let activeTab = 'all'; let search = ''; // Sort state: column + direction, persisted to localStorage let sortState = (function () { try { const saved = JSON.parse(localStorage.getItem('meshcore-nodes-sort')); if (saved && saved.column && saved.direction) return saved; } catch {} return { column: 'last_seen', direction: 'desc' }; })(); function toggleSort(column) { if (sortState.column === column) { sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc'; } else { // Default direction per column type const descDefault = ['last_seen', 'advert_count']; sortState = { column, direction: descDefault.includes(column) ? 'desc' : 'asc' }; } localStorage.setItem('meshcore-nodes-sort', JSON.stringify(sortState)); } function sortNodes(arr) { const col = sortState.column; const dir = sortState.direction === 'asc' ? 1 : -1; return arr.sort(function (a, b) { let va, vb; if (col === 'name') { va = (a.name || '').toLowerCase(); vb = (b.name || '').toLowerCase(); if (!a.name && b.name) return 1; if (a.name && !b.name) return -1; return va < vb ? -dir : va > vb ? dir : 0; } else if (col === 'public_key') { va = a.public_key || ''; vb = b.public_key || ''; return va < vb ? -dir : va > vb ? dir : 0; } else if (col === 'role') { va = (a.role || '').toLowerCase(); vb = (b.role || '').toLowerCase(); return va < vb ? -dir : va > vb ? dir : 0; } else if (col === 'last_seen') { va = a.last_heard ? new Date(a.last_heard).getTime() : a.last_seen ? new Date(a.last_seen).getTime() : 0; vb = b.last_heard ? new Date(b.last_heard).getTime() : b.last_seen ? new Date(b.last_seen).getTime() : 0; return (va - vb) * dir; } else if (col === 'advert_count') { va = a.advert_count || 0; vb = b.advert_count || 0; return (va - vb) * dir; } return 0; }); } function sortArrow(col) { if (sortState.column !== col) return ''; return '' + (sortState.direction === 'asc' ? '▲' : '▼') + ''; } let lastHeard = localStorage.getItem('meshcore-nodes-last-heard') || ''; let statusFilter = localStorage.getItem('meshcore-nodes-status-filter') || 'all'; let wsHandler = null; let detailMap = null; // ROLE_COLORS loaded from shared roles.js const TABS = [ { key: 'all', label: 'All' }, { key: 'repeater', label: 'Repeaters' }, { key: 'room', label: 'Rooms' }, { key: 'companion', label: 'Companions' }, { key: 'sensor', label: 'Sensors' }, ]; /* === Shared helper functions for node detail rendering === */ function getStatusTooltip(role, status) { const isInfra = role === 'repeater' || role === 'room'; const threshold = isInfra ? '72h' : '24h'; if (status === 'active') { return 'Active \u2014 heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : ''); } if (role === 'companion') { return 'Stale \u2014 not heard for over ' + threshold + '. Companions only advertise when the user initiates \u2014 this may be normal.'; } if (role === 'sensor') { return 'Stale \u2014 not heard for over ' + threshold + '. This sensor may be offline.'; } return 'Stale \u2014 not heard for over ' + threshold + '. This ' + role + ' may be offline or out of range.'; } function getStatusInfo(n) { // Single source of truth for all status-related info const role = (n.role || '').toLowerCase(); const roleColor = ROLE_COLORS[n.role] || '#6b7280'; // Prefer last_heard (from in-memory packets) > _lastHeard (health API) > last_seen (DB) const lastHeardTime = n._lastHeard || n.last_heard || n.last_seen; const lastHeardMs = lastHeardTime ? new Date(lastHeardTime).getTime() : 0; const status = getNodeStatus(role, lastHeardMs); const statusTooltip = getStatusTooltip(role, status); const statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale'; const statusAge = lastHeardMs ? (Date.now() - lastHeardMs) : Infinity; let explanation = ''; if (status === 'active') { explanation = 'Last heard ' + (lastHeardTime ? timeAgo(lastHeardTime) : 'unknown'); } else { const ageDays = Math.floor(statusAge / 86400000); const ageHours = Math.floor(statusAge / 3600000); const ageStr = ageDays >= 1 ? ageDays + 'd' : ageHours + 'h'; const isInfra = role === 'repeater' || role === 'room'; const reason = isInfra ? 'repeaters typically advertise every 12-24h' : 'companions only advertise when user initiates, this may be normal'; explanation = 'Not heard for ' + ageStr + ' — ' + reason; } return { status, statusLabel, statusTooltip, statusAge, explanation, roleColor, lastHeardMs, role }; } function renderNodeBadges(n, roleColor) { // Returns HTML for: role badge, hash prefix badge, hash inconsistency link, status label const info = getStatusInfo(n); let html = `${n.role}`; if (n.hash_size) { html += ` ${n.public_key.slice(0, n.hash_size * 2).toUpperCase()}`; } if (n.hash_size_inconsistent) { html += ` ⚠️ variable hash size`; } html += ` ${info.statusLabel}`; return html; } function renderStatusExplanation(n) { const info = getStatusInfo(n); return `
${info.statusLabel} — ${info.explanation}
`; } function renderHashInconsistencyWarning(n) { if (!n.hash_size_inconsistent) return ''; return `
Adverts show varying hash sizes (${(n.hash_sizes_seen||[]).join('-byte, ')}-byte). This is a known bug where automatic adverts ignore the configured multibyte path setting. Fixed in repeater v1.14.1.
`; } let directNode = null; // set when navigating directly to #/nodes/:pubkey let regionChangeHandler = null; function init(app, routeParam) { directNode = routeParam || null; if (directNode) { // Full-screen single node view app.innerHTML = `
Loading…
Loading…
`; document.getElementById('nodeBackBtn').addEventListener('click', () => { location.hash = '#/nodes'; }); loadFullNode(directNode); // Escape to go back to nodes list document.addEventListener('keydown', function nodesEsc(e) { if (e.key === 'Escape') { document.removeEventListener('keydown', nodesEsc); location.hash = '#/nodes'; } }); return; } app.innerHTML = `
Select a node to view details
`; RegionFilter.init(document.getElementById('nodesRegionFilter')); regionChangeHandler = RegionFilter.onChange(function () { _allNodes = null; loadNodes(); }); document.getElementById('nodeSearch').addEventListener('input', debounce(e => { search = e.target.value; loadNodes(); }, 250)); loadNodes(); wsHandler = debouncedOnWS(function (msgs) { if (msgs.some(function (m) { return m.type === 'packet'; })) loadNodes(); }); } async function loadFullNode(pubkey) { const body = document.getElementById('nodeFullBody'); try { const [nodeData, healthData] = await Promise.all([ api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }), api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null) ]); const n = nodeData.node; const adverts = (nodeData.recentAdverts || []).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); const title = document.querySelector('.node-full-title'); if (title) title.textContent = n.name || pubkey.slice(0, 12); const hasLoc = n.lat != null && n.lon != null; // Health stats const h = healthData || {}; const stats = h.stats || {}; const observers = h.observers || []; const recent = h.recentPackets || []; const lastHeard = stats.lastHeard; // Attach health lastHeard for shared helpers n._lastHeard = lastHeard || n.last_seen; const si = getStatusInfo(n); const roleColor = si.roleColor; const statusLabel = si.statusLabel; const statusExplanation = si.explanation; body.innerHTML = `
${escapeHtml(n.name || '(unnamed)')}
${renderNodeBadges(n, roleColor)}
${renderHashInconsistencyWarning(n)}
${n.public_key}
📊 Analytics
${hasLoc ? `
` : ''}
${n.public_key.slice(0, 16)}…${n.public_key.slice(-8)}
${stats.avgSnr != null ? `` : ''} ${stats.avgHops ? `` : ''} ${hasLoc ? `` : ''}
Status${statusLabel} ${statusExplanation}
Last Heard${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}
First Seen${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}
Total Packets${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' (seen ' + stats.totalObservations + '×)' : ''}
Packets Today${stats.packetsToday || 0}
Avg SNR${stats.avgSnr.toFixed(1)} dB
Avg Hops${stats.avgHops}
Location${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}
Hash Prefix${n.hash_size ? '' + n.public_key.slice(0, n.hash_size * 2).toUpperCase() + ' (' + n.hash_size + '-byte)' : 'Unknown'}${n.hash_size_inconsistent ? ' ⚠️ varies' : ''}
${observers.length ? `
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `
Regions: ${regions.map(r => '' + escapeHtml(r) + '').join(' ')}
` : ''; })()}

Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})

${observers.map(o => ``).join('')}
ObserverRegionPacketsAvg SNRAvg RSSI
${escapeHtml(o.observer_name || o.observer_id)} ${o.iata ? escapeHtml(o.iata) : '—'} ${o.packetCount} ${o.avgSnr != null ? o.avgSnr.toFixed(1) + ' dB' : '—'} ${o.avgRssi != null ? o.avgRssi.toFixed(0) + ' dBm' : '—'}
` : ''}

Paths Through This Node

Loading paths…

Recent Packets (${adverts.length})

${adverts.length ? adverts.map(p => { let decoded; try { decoded = JSON.parse(p.decoded_json); } catch {} const typeLabel = p.payload_type === 4 ? '📡 Advert' : p.payload_type === 5 ? '💬 Channel' : p.payload_type === 2 ? '✉️ DM' : '📦 Packet'; const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : ''; const obs = p.observer_name || p.observer_id; const snr = p.snr != null ? ` · SNR ${p.snr}dB` : ''; const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : ''; const obsBadge = p.observation_count > 1 ? ` 👁 ${p.observation_count}` : ''; // Show hash size per advert if inconsistent let hashSizeBadge = ''; if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) { const pb = parseInt(p.raw_hex.slice(2, 4), 16); const hs = ((pb >> 6) & 0x3) + 1; const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316'; const hsFg = hs === 2 ? '#064e3b' : '#fff'; hashSizeBadge = ` ${hs}B`; } return `
${timeAgo(p.timestamp)} ${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi} Analyze →
`; }).join('') : '
No recent packets
'}
`; // Map if (hasLoc) { try { if (detailMap) { detailMap.remove(); detailMap = null; } detailMap = L.map('nodeFullMap', { zoomControl: true, attributionControl: false }).setView([n.lat, n.lon], 13); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap); L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12)); setTimeout(() => detailMap.invalidateSize(), 100); } catch {} } // Copy URL const nodeUrl = location.origin + '#/nodes/' + encodeURIComponent(n.public_key); document.getElementById('copyUrlBtn')?.addEventListener('click', () => { navigator.clipboard.writeText(nodeUrl).then(() => { const btn = document.getElementById('copyUrlBtn'); btn.textContent = '✅ Copied!'; setTimeout(() => btn.textContent = '📋 Copy URL', 2000); }).catch(() => {}); }); // Deep-link scroll: ?section=node-packets or ?section=node-packets const hashParams = location.hash.split('?')[1] || ''; const urlParams = new URLSearchParams(hashParams); const scrollTarget = urlParams.get('section') || (urlParams.has('highlight') ? 'node-packets' : null); if (scrollTarget) { const targetEl = document.getElementById(scrollTarget); if (targetEl) setTimeout(() => targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' }), 300); } // QR code for full-screen view const qrFullEl = document.getElementById('nodeFullQrCode'); if (qrFullEl && typeof qrcode === 'function') { try { const typeMap = { companion: 1, repeater: 2, room: 3, sensor: 4 }; const contactType = typeMap[(n.role || '').toLowerCase()] || 2; const meshcoreUrl = `meshcore://contact/add?name=${encodeURIComponent(n.name || 'Unknown')}&public_key=${n.public_key}&type=${contactType}`; const qr = qrcode(0, 'M'); qr.addData(meshcoreUrl); qr.make(); qrFullEl.innerHTML = qr.createSvgTag(3, 0); const svg = qrFullEl.querySelector('svg'); if (svg) { svg.style.display = 'block'; svg.style.margin = '0 auto'; } } catch {} } // Fetch paths through this node (full-screen view) api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => { const el = document.getElementById('fullPathsContent'); if (!el) return; if (!pathData || !pathData.paths || !pathData.paths.length) { el.innerHTML = '
No paths observed through this node
'; return; } document.querySelector('#fullPathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique, ${pathData.totalTransmissions} transmissions)`; const COLLAPSE_LIMIT = 10; function renderPaths(paths) { return paths.map(p => { const chain = p.hops.map(h => { const isThis = h.pubkey === n.public_key; if (window.HopDisplay) { const entry = { name: h.name, pubkey: h.pubkey, ambiguous: h.ambiguous, conflicts: h.conflicts, totalGlobal: h.totalGlobal, totalRegional: h.totalRegional, globalFallback: h.globalFallback, unreliable: h.unreliable }; const html = HopDisplay.renderHop(h.prefix, entry); return isThis ? html.replace('class="', 'class="hop-current ') : html; } const name = escapeHtml(h.name || h.prefix); const link = h.pubkey ? `${name}` : `${name}`; return link; }).join(' → '); return `
${chain}
${p.count}× · last ${timeAgo(p.lastSeen)} · Analyze →
`; }).join(''); } if (pathData.paths.length <= COLLAPSE_LIMIT) { el.innerHTML = renderPaths(pathData.paths); } else { el.innerHTML = renderPaths(pathData.paths.slice(0, COLLAPSE_LIMIT)) + ``; document.getElementById('showAllFullPaths').addEventListener('click', function() { el.innerHTML = renderPaths(pathData.paths); }); } }).catch(() => { const el = document.getElementById('fullPathsContent'); if (el) el.innerHTML = '
Failed to load paths
'; }); } catch (e) { body.innerHTML = `
Failed to load node: ${e.message}
`; } } function destroy() { if (wsHandler) offWS(wsHandler); wsHandler = null; if (detailMap) { detailMap.remove(); detailMap = null; } if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler); regionChangeHandler = null; nodes = []; selectedKey = null; } let _allNodes = null; // cached full node list async function loadNodes() { try { // Fetch all nodes once, filter client-side if (!_allNodes) { const params = new URLSearchParams({ limit: '5000' }); const rp = RegionFilter.getRegionParam(); if (rp) params.set('region', rp); const data = await api('/nodes?' + params, { ttl: CLIENT_TTL.nodeList }); _allNodes = data.nodes || []; counts = data.counts || {}; } // Client-side filtering let filtered = _allNodes; if (activeTab !== 'all') filtered = filtered.filter(n => (n.role || '').toLowerCase() === activeTab); if (search) { const q = search.toLowerCase(); filtered = filtered.filter(n => (n.name || '').toLowerCase().includes(q) || (n.public_key || '').toLowerCase().includes(q)); } if (lastHeard) { const ms = { '1h': 3600000, '2h': 7200000, '6h': 21600000, '12h': 43200000, '24h': 86400000, '48h': 172800000, '3d': 259200000, '7d': 604800000, '14d': 1209600000, '30d': 2592000000 }[lastHeard]; if (ms) filtered = filtered.filter(n => { const t = n.last_heard || n.last_seen; return t && (Date.now() - new Date(t).getTime()) < ms; }); } // Status filter (active/stale) if (statusFilter === 'active' || statusFilter === 'stale') { filtered = filtered.filter(n => { const role = (n.role || 'companion').toLowerCase(); const t = n.last_heard || n.last_seen; const lastMs = t ? new Date(t).getTime() : 0; return getNodeStatus(role, lastMs) === statusFilter; }); } nodes = filtered; // Defensive filter: hide nodes with obviously corrupted data nodes = nodes.filter(n => { if (n.public_key && n.public_key.length < 16) return false; if (!n.name && !n.advert_count) return false; return true; }); // Ensure claimed nodes are always present even if not in current page const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]'); const existingKeys = new Set(nodes.map(n => n.public_key)); const missing = myNodes.filter(mn => !existingKeys.has(mn.pubkey)); if (missing.length) { const fetched = await Promise.allSettled( missing.map(mn => api('/nodes/' + encodeURIComponent(mn.pubkey), { ttl: CLIENT_TTL.nodeDetail })) ); fetched.forEach(r => { if (r.status === 'fulfilled' && r.value && r.value.public_key) nodes.push(r.value); }); } // Auto-sync claimed → favorites syncClaimedToFavorites(); renderCounts(); renderLeft(); } catch (e) { console.error('Failed to load nodes:', e); const tbody = document.getElementById('nodesBody'); if (tbody) tbody.innerHTML = '
Failed to load nodes. Please try again.
'; } } function renderCounts() { const el = document.getElementById('nodeCounts'); if (!el) return; el.innerHTML = [ { k: 'repeaters', l: 'Repeaters', c: ROLE_COLORS.repeater }, { k: 'rooms', l: 'Rooms', c: ROLE_COLORS.room || '#6b7280' }, { k: 'companions', l: 'Companions', c: ROLE_COLORS.companion }, { k: 'sensors', l: 'Sensors', c: ROLE_COLORS.sensor }, ].map(r => `${counts[r.k] || 0} ${r.l}`).join(''); } function renderLeft() { const el = document.getElementById('nodesLeft'); if (!el) return; el.innerHTML = `
${TABS.map(t => ``).join('')}
Name${sortArrow('name')} Public Key${sortArrow('public_key')} Role${sortArrow('role')} Last Seen${sortArrow('last_seen')} Adverts${sortArrow('advert_count')}
`; // Tab clicks const nodeTabs = document.getElementById('nodeTabs'); initTabBar(nodeTabs); el.querySelectorAll('.node-tab').forEach(btn => { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; loadNodes(); }); }); // Filter changes document.getElementById('nodeLastHeard').addEventListener('change', e => { lastHeard = e.target.value; localStorage.setItem('meshcore-nodes-last-heard', lastHeard); loadNodes(); }); // Status filter buttons document.querySelectorAll('#nodeStatusFilter .btn').forEach(btn => { btn.addEventListener('click', () => { statusFilter = btn.dataset.status; localStorage.setItem('meshcore-nodes-status-filter', statusFilter); document.querySelectorAll('#nodeStatusFilter .btn').forEach(b => b.classList.toggle('active', b.dataset.status === statusFilter)); loadNodes(); }); }); // Sortable column headers el.querySelectorAll('th.sortable').forEach(th => { th.addEventListener('click', () => { toggleSort(th.dataset.sort); renderLeft(); }); }); // Delegated click/keyboard handler for table rows const tbody = document.getElementById('nodesBody'); if (tbody) { const handler = (e) => { const row = e.target.closest('tr[data-action="select"]'); if (!row) return; if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; if (e.type === 'keydown') e.preventDefault(); selectNode(row.dataset.value); }; tbody.addEventListener('click', handler); tbody.addEventListener('keydown', handler); } // Escape to close node detail panel document.addEventListener('keydown', function nodesPanelEsc(e) { if (e.key === 'Escape') { const panel = document.getElementById('nodesRight'); if (panel && !panel.classList.contains('empty')) { panel.classList.add('empty'); panel.innerHTML = 'Select a node to view details'; selectedKey = null; renderRows(); } } }); renderRows(); } function renderRows() { const tbody = document.getElementById('nodesBody'); if (!tbody) return; if (!nodes.length) { tbody.innerHTML = 'No nodes found'; return; } // Claimed ("My Mesh") nodes always on top, then favorites, then sort const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]'); const myKeys = new Set(myNodes.map(n => n.pubkey)); const favs = getFavorites(); const sorted = sortNodes([...nodes]); // Stable re-sort: claimed first, then favorites, preserving sort within each group sorted.sort((a, b) => { const aMy = myKeys.has(a.public_key) ? 0 : 1; const bMy = myKeys.has(b.public_key) ? 0 : 1; if (aMy !== bMy) return aMy - bMy; const aFav = favs.includes(a.public_key) ? 0 : 1; const bFav = favs.includes(b.public_key) ? 0 : 1; return aFav - bFav; }); tbody.innerHTML = sorted.map(n => { const roleColor = ROLE_COLORS[n.role] || '#6b7280'; const isClaimed = myKeys.has(n.public_key); const lastSeenTime = n.last_heard || n.last_seen; const status = getNodeStatus(n.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0); const lastSeenClass = status === 'active' ? 'last-seen-active' : 'last-seen-stale'; return ` ${favStar(n.public_key, 'node-fav')}${isClaimed ? ' ' : ''}${n.name || '(unnamed)'} ${truncate(n.public_key, 16)} ${n.role} ${timeAgo(n.last_heard || n.last_seen)} ${n.advert_count || 0} `; }).join(''); bindFavStars(tbody); makeColumnsResizable('#nodesTable', 'meshcore-nodes-col-widths'); } async function selectNode(pubkey) { // On mobile, navigate to full-screen node view if (window.innerWidth <= 640) { location.hash = '#/nodes/' + encodeURIComponent(pubkey); return; } selectedKey = pubkey; renderRows(); const panel = document.getElementById('nodesRight'); panel.classList.remove('empty'); panel.innerHTML = '
Loading…
'; try { const [data, healthData] = await Promise.all([ api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }), api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null) ]); data.healthData = healthData; renderDetail(panel, data); } catch (e) { panel.innerHTML = `
Error: ${e.message}
`; } } function renderDetail(panel, data) { const n = data.node; const adverts = (data.recentAdverts || []).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); const h = data.healthData || {}; const stats = h.stats || {}; const observers = h.observers || []; const recent = h.recentPackets || []; const hasLoc = n.lat != null && n.lon != null; const nodeUrl = location.origin + '#/nodes/' + encodeURIComponent(n.public_key); // Status calculation via shared helper const lastHeard = stats.lastHeard; n._lastHeard = lastHeard || n.last_seen; const si = getStatusInfo(n); const roleColor = si.roleColor; const totalPackets = stats.totalTransmissions || stats.totalPackets || n.advert_count || 0; panel.innerHTML = `
${escapeHtml(n.name || '(unnamed)')}
${renderNodeBadges(n, roleColor)} 🔍 Details 📊 Analytics
${renderStatusExplanation(n)} ${hasLoc ? `
` : `
`}
${n.public_key}

Overview

Last Heard
${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}
First Seen
${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}
Total Packets
${totalPackets}
Packets Today
${stats.packetsToday || 0}
${stats.avgSnr != null ? `
Avg SNR
${stats.avgSnr.toFixed(1)} dB
` : ''} ${stats.avgHops ? `
Avg Hops
${stats.avgHops}
` : ''} ${hasLoc ? `
Location
${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}
` : ''}
${observers.length ? `
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `
Regions: ${regions.join(', ')}
` : ''; })()}

Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})

${observers.map(o => `
${escapeHtml(o.observer_name || o.observer_id)}${o.iata ? ' ' + escapeHtml(o.iata) + '' : ''} ${o.packetCount} pkts · ${o.avgSnr != null ? 'SNR ' + o.avgSnr.toFixed(1) + 'dB' : ''}${o.avgRssi != null ? ' · RSSI ' + o.avgRssi.toFixed(0) : ''}
`).join('')}
` : ''}

Paths Through This Node

Loading paths…

Recent Packets (${adverts.length})

${adverts.length ? adverts.map(a => { let decoded; try { decoded = JSON.parse(a.decoded_json); } catch {} const pType = PAYLOAD_TYPES[a.payload_type] || 'Packet'; const icon = a.payload_type === 4 ? '📡' : a.payload_type === 5 ? '💬' : a.payload_type === 2 ? '✉️' : '📦'; const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : ''; const obs = a.observer_name || a.observer_id; return ``; }).join('') : '
No recent packets
'}
`; // Init map if (hasLoc) { try { if (detailMap) { detailMap.remove(); detailMap = null; } detailMap = L.map('nodeMap', { zoomControl: false, attributionControl: false }).setView([n.lat, n.lon], 13); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap); L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12)); setTimeout(() => detailMap.invalidateSize(), 100); } catch {} } // QR code — meshcore://contact/add format (scannable by MeshCore app) const qrEl = document.getElementById('nodeQrCode'); if (qrEl && typeof qrcode === 'function') { try { const typeMap = { companion: 1, repeater: 2, room: 3, sensor: 4 }; const contactType = typeMap[(n.role || '').toLowerCase()] || 2; const meshcoreUrl = `meshcore://contact/add?name=${encodeURIComponent(n.name || 'Unknown')}&public_key=${n.public_key}&type=${contactType}`; const qr = qrcode(0, 'M'); qr.addData(meshcoreUrl); qr.make(); const isOverlay = !!qrEl.closest('.node-map-qr-overlay'); qrEl.innerHTML = qr.createSvgTag(3, 0); const svg = qrEl.querySelector('svg'); if (svg) { svg.style.display = 'block'; svg.style.margin = '0 auto'; // Make QR background transparent for map overlay if (isOverlay) { svg.querySelectorAll('rect').forEach(r => { const fill = (r.getAttribute('fill') || '').toLowerCase(); if (fill === '#ffffff' || fill === 'white' || fill === '#fff') { r.setAttribute('fill', 'transparent'); } }); } } } catch {} } // Fetch paths through this node api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => { const el = document.getElementById('pathsContent'); if (!el) return; if (!pathData || !pathData.paths || !pathData.paths.length) { el.innerHTML = '
No paths observed through this node
'; document.querySelector('#pathsSection h4').textContent = 'Paths Through This Node'; return; } document.querySelector('#pathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique path${pathData.totalPaths !== 1 ? 's' : ''}, ${pathData.totalTransmissions} transmissions)`; const COLLAPSE_LIMIT = 10; const showAll = pathData.paths.length <= COLLAPSE_LIMIT; function renderPaths(paths) { return paths.map(p => { const chain = p.hops.map(h => { const isThis = h.pubkey === n.public_key; const name = escapeHtml(h.name || h.prefix); const link = h.pubkey ? `${name}` : `${name}`; return link; }).join(' → '); return `
${chain}
${p.count}× · last ${timeAgo(p.lastSeen)} · Analyze →
`; }).join(''); } if (showAll) { el.innerHTML = renderPaths(pathData.paths); } else { el.innerHTML = renderPaths(pathData.paths.slice(0, COLLAPSE_LIMIT)) + ``; document.getElementById('showAllPaths').addEventListener('click', function() { el.innerHTML = renderPaths(pathData.paths); }); } }).catch(() => { const el = document.getElementById('pathsContent'); if (el) el.innerHTML = '
Failed to load paths
'; }); } registerPage('nodes', { init, destroy }); })();