/* === CoreScope — 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 // Managed by TableSort utility (public/table-sort.js) when DOM is available, // falls back to simple object for unit testing var _nodesTableSortCtrl = null; // TODO(M5): remove fallback when tests use DOM sandbox var _fallbackSortState = null; // used when TableSort controller not initialized (tests) function _getSortState() { if (_nodesTableSortCtrl) return _nodesTableSortCtrl.getState(); if (_fallbackSortState) return _fallbackSortState; try { var saved = JSON.parse(localStorage.getItem('meshcore-nodes-sort')); if (saved && saved.column && saved.direction) return saved; } catch (e) { /* ignore */ } return { column: 'last_seen', direction: 'desc' }; } function sortNodes(arr) { var sortState = _getSortState(); 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 === 'default_scope') { va = (a.default_scope || '').toLowerCase(); vb = (b.default_scope || '').toLowerCase(); if (!a.default_scope && b.default_scope) return 1; if (a.default_scope && !b.default_scope) return -1; 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; }); } 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' }, ]; function buildNodesQuery(tab, searchStr) { var parts = []; if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab)); if (searchStr) parts.push('search=' + encodeURIComponent(searchStr)); // #749 — encode current sort state (default 'last_seen:desc' is omitted). if (window.URLState) { var st = _getSortState(); var isDefault = st.column === 'last_seen' && st.direction === 'desc'; if (!isDefault) { var token = URLState.serializeSort(st.column, st.direction); if (token) parts.push('sort=' + encodeURIComponent(token)); } } return parts.length ? '?' + parts.join('&') : ''; } window.buildNodesQuery = buildNodesQuery; function updateNodesUrl() { // Preserve subpath (e.g. #/nodes/) so this doesn't break detail deep-links. var cur = String(location.hash || ''); var subpath = ''; var m = cur.match(/^#\/nodes(\/[^?]*)?/); if (m && m[1]) subpath = m[1]; history.replaceState(null, '', '#/nodes' + subpath + buildNodesQuery(activeTab, search)); } function renderNodeTimestampHtml(isoString) { if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') { return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—'); } const f = formatTimestampWithTooltip(isoString, getTimestampMode()); const warn = f.isFuture ? ' ⚠️' : ''; return `${escapeHtml(f.text)}${warn}`; } function renderNodeTimestampText(isoString) { if (typeof formatTimestamp !== 'function' || typeof getTimestampMode !== 'function') { return typeof timeAgo === 'function' ? timeAgo(isoString) : '—'; } return formatTimestamp(isoString, getTimestampMode()); } /* === Shared helper functions for node detail rendering === */ function getStatusTooltip(role, status) { const isInfra = role === 'repeater' || role === 'room'; const threshMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs; const threshold = threshMs >= 3600000 ? Math.round(threshMs / 3600000) + 'h' : Math.round(threshMs / 60000) + 'm'; 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 ? renderNodeTimestampText(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()}`; } // #1279 P2 #4: multibyte capability badge — surfaced from the observable // multibyte hash_size (firmware Feat1/Feat2 carry the wire capability bits // per AdvertDataHelpers.h:14-16, but Feat1/Feat2 aren't persisted per-node // in CoreScope today; hash_size is the observed effective capability). if (n.hash_size && Number(n.hash_size) >= 2) { html += ` Multibyte: ${Number(n.hash_size)}-byte`; } 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 ''; const sizes = Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : []; return `
Adverts show varying hash sizes (${sizes.join('-byte, ')}-byte). This is a known bug where automatic adverts ignore the configured multibyte path setting. Fixed in repeater v1.14.1.
`; } // ─── Neighbor section helpers ─────────────────────────────────────────────── // Cache: pubkey → { data, ts } var _neighborCache = {}; function getConfidenceIndicator(entry) { if (entry.ambiguous) return { icon: '⚠️', label: 'AMBIGUOUS', cls: 'confidence-ambiguous' }; if (entry.count <= 1) return { icon: '🔴', label: 'LOW', cls: 'confidence-low' }; if (entry.score >= 0.5 && entry.count >= 3) return { icon: '🟢', label: 'HIGH', cls: 'confidence-high' }; return { icon: '🟡', label: 'MEDIUM', cls: 'confidence-medium' }; } function renderNeighborRows(neighbors, limit) { var sorted = neighbors.slice().sort(function(a, b) { return (b.count || 0) - (a.count || 0); }); var items = limit ? sorted.slice(0, limit) : sorted; return items.map(function(nb) { var conf = getConfidenceIndicator(nb); var name = nb.name || (nb.prefix + '… (unknown)'); var nameHtml = nb.pubkey ? '' + escapeHtml(name) + '' : '' + escapeHtml(name) + ''; var role = nb.role || '—'; var roleBadge = nb.role ? '' + escapeHtml(role) + '' : ''; var scoreTitle = 'Observations: ' + nb.count; if (nb.avg_snr != null) scoreTitle += ' · Avg SNR: ' + Number(nb.avg_snr).toFixed(1) + ' dB'; var distanceCell = nb.distance_km != null ? formatDistance(Number(nb.distance_km)) : ''; var showOnMap = nb.pubkey ? ' ' : ''; var lastSeenVal = nb.last_seen ? new Date(nb.last_seen).getTime() : 0; var distanceVal = nb.distance_km != null ? Number(nb.distance_km) : ''; return '' + '' + nameHtml + '' + '' + roleBadge + '' + '' + Number(nb.score).toFixed(2) + '' + '' + nb.count + '' + '' + renderNodeTimestampHtml(nb.last_seen) + '' + '' + distanceCell + '' + '' + conf.icon + '' + '' + showOnMap + '' + ''; }).join(''); } function renderNeighborTable(neighbors, limit) { return '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + renderNeighborRows(neighbors, limit) + '
NeighborRoleScoreObsLast SeenDistanceConf
'; } function fetchAndRenderNeighbors(pubkey, containerId, opts) { opts = opts || {}; var limit = opts.limit || 0; var headerSelector = opts.headerSelector; var viewAllPubkey = opts.viewAllPubkey; // Always set spinner as initial DOM state (synchronous) so tests can observe it var spinnerEl = document.getElementById(containerId); if (spinnerEl) spinnerEl.innerHTML = '
Loading neighbors…
'; // Check cache var cached = _neighborCache[pubkey]; if (cached && (Date.now() - cached.ts < 300000)) { // 5 min cache renderNeighborData(cached.data, containerId, limit, headerSelector, viewAllPubkey); return; } api('/nodes/' + encodeURIComponent(pubkey) + '/neighbors', { ttl: CLIENT_TTL.nodeDetail }).then(function(data) { _neighborCache[pubkey] = { data: data, ts: Date.now() }; renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey); }).catch(function() { var el = document.getElementById(containerId); if (el) el.innerHTML = '
Could not load neighbor data
'; }); } function renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey) { var el = document.getElementById(containerId); if (!el) return; if (!data || !data.neighbors || !data.neighbors.length) { el.innerHTML = '
No neighbor data available yet. Neighbor relationships are built from observed packet paths over time.
'; if (headerSelector) { var h = document.querySelector(headerSelector); if (h) h.textContent = 'Neighbors (0)'; } return; } if (headerSelector) { var h = document.querySelector(headerSelector); if (h) h.textContent = 'Neighbors (' + data.neighbors.length + ')'; } var html = renderNeighborTable(data.neighbors, limit); if (limit && data.neighbors.length > limit) { html += '
'; } else if (!limit && data.neighbors.length > 5) { // Collapse toggle when expanded (#855) html += '
'; } el.innerHTML = html; // Wire "Show all neighbors" expand button (#855) var expandBtn = el.querySelector('.show-all-neighbors-btn'); if (expandBtn) { expandBtn.addEventListener('click', function() { renderNeighborData(data, containerId, 0, headerSelector, null); }); } // Wire collapse button (#855) var collapseBtn = el.querySelector('.collapse-neighbors-btn'); if (collapseBtn) { collapseBtn.addEventListener('click', function() { renderNeighborData(data, containerId, 5, headerSelector, null); }); } // Initialize TableSort on neighbor table var neighborTable = el.querySelector('.neighbor-sort-table'); if (neighborTable && window.TableSort) { TableSort.init(neighborTable, { defaultColumn: 'count', defaultDirection: 'desc' }); } // Wire up "Show on Map" buttons via event delegation el.addEventListener('click', function(e) { var btn = e.target.closest('.neighbor-show-map'); if (!btn) return; var pk = btn.getAttribute('data-pubkey'); if (pk) location.hash = '#/map?node=' + encodeURIComponent(pk); }); } // ─── End neighbor helpers ───────────────────────────────────────────────── 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 (desktop + mobile). // Reached via the 🔍 Details link or a deep link to #/nodes/{pubkey}. // Row clicks use history.replaceState (no hashchange → no re-init), // so the split-panel UX on desktop is preserved. 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; } // Reset list-view state to defaults, then override from URL params activeTab = 'all'; search = ''; const _listUrlParams = getHashParams(); const _urlTab = _listUrlParams.get('tab'); const _urlSearch = _listUrlParams.get('search'); if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab; if (_urlSearch) search = _urlSearch; // #749 — restore sort from URL (overrides localStorage persistence). var _urlSort = _listUrlParams.get('sort'); if (_urlSort && window.URLState) { var _parsedSort = URLState.parseSort(_urlSort); if (_parsedSort && _parsedSort.column) { try { localStorage.setItem('meshcore-nodes-sort', JSON.stringify(_parsedSort)); } catch {} _fallbackSortState = _parsedSort; } } app.innerHTML = `
Select a node to view details
`; RegionFilter.init(document.getElementById('nodesRegionFilter')); AreaFilter.init(document.getElementById('nodesAreaFilter')); regionChangeHandler = RegionFilter.onChange(function () { _allNodes = null; _fleetSkew = null; loadNodes(); }); AreaFilter.onChange(function () { _allNodes = null; _fleetSkew = null; loadNodes(); }); if (search) { var _si = document.getElementById('nodeSearch'); if (_si) _si.value = search; } document.getElementById('nodeSearch').addEventListener('input', debounce(e => { search = e.target.value; updateNodesUrl(); loadNodes(); }, 250)); loadNodes(); if (directNode) selectNode(directNode); // Auto-refresh when ADVERT packets arrive via WebSocket (fixes #131) wsHandler = debouncedOnWS(function (msgs) { const advertMsgs = msgs.filter(isAdvertMessage); if (!advertMsgs.length) return; if (!_allNodes) { invalidateApiCache('/nodes'); loadNodes(true); return; } let needReload = false; for (const m of advertMsgs) { const payload = m.data && m.data.decoded && m.data.decoded.payload; const pubKey = payload && (payload.pubKey || payload.public_key); if (!pubKey) { needReload = true; break; } const existing = _allNodes.find(n => n.public_key === pubKey); if (existing) { if (payload.name) existing.name = payload.name; if (payload.lat != null) existing.lat = payload.lat; if (payload.lon != null) existing.lon = payload.lon; const ts = m.data.packet && (m.data.packet.timestamp || m.data.packet.first_seen); if (ts) existing.last_seen = ts; } else { needReload = true; break; } } if (needReload) { _allNodes = null; _fleetSkew = null; invalidateApiCache('/nodes'); } loadNodes(true); }, 5000); } /** * Fetch node detail + health data in parallel. * Both selectNode() and loadFullNode() need the same data — * this shared helper avoids duplicating the fetch logic (fixes #391). */ async function fetchNodeDetail(pubkey) { 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) ]); nodeData.healthData = healthData; return nodeData; } async function loadFullNode(pubkey) { const body = document.getElementById('nodeFullBody'); try { const nodeData = await fetchNodeDetail(pubkey); const healthData = nodeData.healthData; 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; const dupMap = buildDupNameMap(_allNodes); const dupBadge = dupNameBadge(n.name, n.public_key, dupMap); const dupKeys = n.name && dupMap[n.name.toLowerCase()] ? dupMap[n.name.toLowerCase()].filter(function(k) { return k !== n.public_key; }) : []; const dupSection = dupKeys.length ? '
Also known as: ' + dupKeys.map(function(k) { return '' + escapeHtml(k.slice(0, 12)) + '…'; }).join(', ') + '
' : ''; body.innerHTML = `
${escapeHtml(n.name || '(unnamed)')}${dupBadge}
${dupSection}
${renderNodeBadges(n, roleColor)}
${renderHashInconsistencyWarning(n)}
${n.public_key}
📊 Analytics
${hasLoc ? `
` : ''}
${n.public_key.slice(0, 16)}…${n.public_key.slice(-8)}
${(n.role === 'repeater' || n.role === 'room') ? `` : ''} ${(n.role === 'repeater' || n.role === 'room') && n.usefulness_score != null ? (() => { const s = Number(n.usefulness_score) || 0; const pct = (s * 100).toFixed(1); // Visual indicator: width % bar with green→yellow→red color by score. // Per issue #672 classification table: 0.8+ Critical, 0.6+ Valuable, // 0.3+ Moderate, 0.1+ Marginal, else Redundant. let label, color; if (s >= 0.8) { label = 'Critical'; color = 'var(--status-green, #2ecc71)'; } else if (s >= 0.6) { label = 'Valuable'; color = 'var(--status-green, #2ecc71)'; } else if (s >= 0.3) { label = 'Moderate'; color = 'var(--status-yellow, #f1c40f)'; } else if (s >= 0.1) { label = 'Marginal'; color = 'var(--status-orange, #e67e22)'; } else { label = 'Redundant'; color = 'var(--status-red, #e74c3c)'; } const barWidth = Math.max(2, Math.round(s * 100)); return ``; })() : ''} ${(n.role === 'repeater' || n.role === 'room') && n.bridge_score != null ? (() => { // Bridge axis (issue #672 axis 2 of 4): normalized betweenness // centrality from the neighbor-edges graph. Distinct from the // Traffic-based Usefulness score above — bridge measures // STRUCTURAL importance (how many shortest paths between // other node pairs go through this one) regardless of // current traffic. const b = Number(n.bridge_score) || 0; const bpct = (b * 100).toFixed(1); let blabel, bcolor; if (b >= 0.5) { blabel = 'Critical bridge'; bcolor = 'var(--status-green, #2ecc71)'; } else if (b >= 0.2) { blabel = 'Important'; bcolor = 'var(--status-green, #2ecc71)'; } else if (b >= 0.05) { blabel = 'Some role'; bcolor = 'var(--status-yellow, #f1c40f)'; } else if (b > 0) { blabel = 'Marginal'; bcolor = 'var(--status-orange, #e67e22)'; } else { blabel = 'No bridge role'; bcolor = 'var(--text-muted)'; } const bbarWidth = Math.max(2, Math.round(b * 100)); return ``; })() : ''} ${stats.avgSnr != null ? `` : ''} ${stats.avgHops ? `` : ''} ${hasLoc ? `` : ''}
Status${statusLabel} ${statusExplanation}
Last Heard${renderNodeTimestampHtml(lastHeard || n.last_seen)}
Last Relayed${n.last_relayed ? renderNodeTimestampHtml(n.last_relayed) + ' ' + (n.relay_active ? '🟢 actively relaying' : '🟡 alive (idle)') : 'never observed as relay hop 🟡 alive (idle)'}${(n.relay_count_1h != null || n.relay_count_24h != null) ? ` (${n.relay_count_1h || 0} relays/hr, ${n.relay_count_24h || 0} relays/24h)` : ''}
Usefulness${pct}% ${label}
Bridge${bpct}% ${blabel}
First Seen${renderNodeTimestampHtml(n.first_seen)}
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${Number(stats.avgSnr).toFixed(1)} dB
Avg Hops${stats.avgHops}
Location${Number(n.lat).toFixed(5)}, ${Number(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' : ''}
${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return `

Recent Packets (${validPackets.length})

${validPackets.length ? validPackets.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); if ((pb & 0x3F) !== 0) { 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 `
${renderNodeTimestampHtml(p.timestamp)} ${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi} Analyze →
`; }).join('') : '
No recent packets
'}
`; })()}
${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('')}
Observer Region Packets Avg SNR Avg RSSI
${escapeHtml(o.observer_name || o.observer_id)} ${o.iata ? escapeHtml(o.iata) : '—'} ${o.packetCount} ${o.avgSnr != null ? Number(o.avgSnr).toFixed(1) + ' dB' : '—'} ${o.avgRssi != null ? Number(o.avgRssi).toFixed(0) + ' dBm' : '—'}
` : ''}

Neighbors

Loading neighbors…

Paths Through This Node

Loading paths…
`; // 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', () => { const btn = document.getElementById('copyUrlBtn'); window.copyToClipboard(nodeUrl, () => { btn.textContent = '✅ Copied!'; setTimeout(() => btn.textContent = '📋 Copy URL', 2000); }); }); // Copy short URL — issue #772. Uses an 8-char pubkey prefix; the // backend resolves it to the canonical pubkey when unambiguous. const shortUrl = location.origin + '#/nodes/' + n.public_key.slice(0, 8); document.getElementById('copyShortUrlBtn')?.addEventListener('click', () => { const btn = document.getElementById('copyShortUrlBtn'); window.copyToClipboard(shortUrl, () => { btn.textContent = '✅ Copied!'; setTimeout(() => btn.textContent = '📡 Copy short URL', 2000); }); }); // 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 {} } // Initialize TableSort on observer table (full detail page) var observerTable = document.querySelector('#node-observers .observer-sort-table'); if (observerTable && window.TableSort) { TableSort.init(observerTable, { defaultColumn: 'packets', defaultDirection: 'desc' }); } // Fetch neighbors for this node (full-screen view) fetchAndRenderNeighbors(n.public_key, 'fullNeighborsContent', { headerSelector: '#fullNeighborsHeader' }); // #690 — Clock Skew detail section (full-screen view) loadClockSkewInto(document.getElementById('node-clock-skew'), n.public_key); // Affinity debug panel — show if debugAffinity is enabled (function loadAffinityDebug() { var show = (window.CLIENT_CONFIG && window.CLIENT_CONFIG.debugAffinity) || localStorage.getItem('meshcore-affinity-debug') === 'true'; var panel = document.getElementById('node-affinity-debug'); if (!show || !panel) return; panel.style.display = ''; var apiKey = localStorage.getItem('meshcore-api-key') || ''; fetch('/api/debug/affinity?node=' + encodeURIComponent(n.public_key), { headers: { 'X-API-Key': apiKey } }) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (data) { var el = document.getElementById('affinityDebugContent'); if (!el) return; var html = ''; // Edges table if (data.edges && data.edges.length) { html += '
Neighbor Edges (' + data.edges.length + ')
'; html += ''; data.edges.forEach(function (e) { var neighbor = e.nodeBName || e.nodeAName || (e.nodeB || e.nodeA || '').substring(0, 8); if (e.nodeA.toLowerCase() === n.public_key.toLowerCase()) { neighbor = e.nodeBName || (e.nodeB || e.prefix || '?').substring(0, 8); } else { neighbor = e.nodeAName || (e.nodeA || '').substring(0, 8); } var status = e.ambiguous ? (e.unresolved ? '❓ Unresolved' : '⚠️ Ambiguous') : (e.resolved ? '✅ Auto-resolved' : '✅ Resolved'); html += ''; }); html += '
NeighborScoreCountLast SeenObserversStatus
' + escapeHtml(neighbor) + '' + (e.score || 0).toFixed(3) + '' + e.weight + '' + (e.lastSeen || '').substring(0, 10) + '' + (e.observers || []).length + '' + status + '
'; } else { html += '
No affinity edges for this node
'; } // Resolutions if (data.resolutions && data.resolutions.length) { html += '
Prefix Resolutions (' + data.resolutions.length + ')
'; data.resolutions.forEach(function (r) { html += '
'; html += 'Prefix: ' + escapeHtml(r.prefix) + ' → '; if (r.method === 'auto-resolved') { html += '✅ ' + escapeHtml(r.chosenName || r.chosen || '?') + ''; html += ' (Jaccard=' + r.chosenJaccard.toFixed(2) + ', ratio=' + ((isFinite(r.ratio) && r.ratio < 100) ? r.ratio.toFixed(1) + '×' : '∞') + ')'; } else { html += '⚠️ Ambiguous'; if (r.ratio) html += ' (ratio=' + r.ratio.toFixed(1) + '×, threshold=' + r.thresholdApplied + '×)'; } // Show disambiguation tier used (M4 resolveWithContext) if (r.tier) { var tierLabels = { 'neighbor_affinity': '🏘️ Affinity', 'geo_proximity': '🌍 Geo', 'gps_preference': '📍 GPS', 'first_match': '🎲 Naive', 'unique_prefix': '✓ Unique', 'no_match': '∅ None' }; html += ' [tier: ' + (tierLabels[r.tier] || escapeHtml(r.tier)) + ']'; } // Candidates table if (r.candidates && r.candidates.length) { html += '
'; r.candidates.forEach(function (c) { var highlight = r.chosen && c.pubkey === r.chosen ? ' style="background:var(--status-green-bg,rgba(34,197,94,0.1))"' : ''; html += ''; }); html += '
CandidateJaccardCount
' + escapeHtml(c.name || c.pubkey.substring(0, 8)) + '' + c.jaccard.toFixed(3) + '' + c.score + '
'; } html += '
'; }); } // Stats summary if (data.stats) { html += '
Graph Stats
'; html += '
'; html += 'Total edges: ' + data.stats.totalEdges + '
'; html += 'Total nodes: ' + data.stats.totalNodes + '
'; html += 'Resolved: ' + data.stats.resolvedCount + ' | Ambiguous: ' + data.stats.ambiguousCount + ' | Unresolved: ' + data.stats.unresolvedCount + '
'; html += 'Avg confidence: ' + (data.stats.avgConfidence || 0).toFixed(3) + '
'; html += 'Cold-start coverage: ' + (data.stats.coldStartCoverage || 0).toFixed(1) + '%
'; html += 'Cache age: ' + (data.stats.cacheAge || 'N/A') + ' | Last rebuild: ' + (data.stats.lastRebuild || 'N/A'); html += '
'; } el.innerHTML = html; }) .catch(function (err) { var el = document.getElementById('affinityDebugContent'); if (el) el.innerHTML = '
Failed to load debug data: ' + escapeHtml(err.message) + '
'; }); })(); // 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 ${renderNodeTimestampHtml(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) { // #1150: surface a real error state in BOTH the back-row title and the body // when /api/nodes/{pubkey} returns 404 (or any failure). Otherwise the title // stays "Loading…" forever and there's no link back to the Nodes list. const msg = (e && e.message) || ''; const is404 = /\b404\b/.test(msg) || /not\s*found/i.test(msg); const titleEl = document.querySelector('.node-full-title'); if (titleEl) { titleEl.textContent = is404 ? 'Node not found — ' + (pubkey || '').slice(0, 12) + '…' : 'Failed to load node'; } const safePubkey = escapeHtml(pubkey || ''); const headline = is404 ? 'Node not found' : 'Failed to load node'; const detail = is404 ? 'No node matched the requested public key on this instance. It may exist on another deployment, or it may have been evicted/blacklisted here.' : 'The node detail API call failed: ' + escapeHtml(msg); body.innerHTML = '
' + '
' + headline + '
' + '
' + safePubkey + '
' + '
' + detail + '
' + '
' + '← Back to Nodes' + '' + '
' + '
'; const retryBtn = document.getElementById('nodeRetryBtn'); if (retryBtn) { retryBtn.addEventListener('click', function () { if (titleEl) titleEl.textContent = 'Loading…'; body.innerHTML = '
Loading…
'; loadFullNode(pubkey); }); } } } 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 _themeRefreshHandler = null; let _allNodes = null; // cached full node list let _fleetSkew = null; // cached clock skew map: pubkey → {severity, recentMedianSkewSec, medianSkewSec, ...} /** * Fetch per-node clock skew and render into the given container element. * Shared between the full-screen detail page and the side panel (#813, #690). * No-op if the container is missing, the API errors, or the response lacks severity. */ /** Build collapsible evidence panel for node clock skew card */ function buildEvidencePanel(cs) { var evidence = cs.recentHashEvidence; if (!evidence || evidence.length === 0) return ''; var calSum = cs.calibrationSummary || {}; var calLine = calSum.totalSamples ? '
Last ' + calSum.totalSamples + ' samples: ' + (calSum.calibratedSamples || 0) + ' corrected via observer calibration, ' + (calSum.uncalibratedSamples || 0) + ' uncorrected (single-observer).
' : ''; // Severity reason. var skewVal = window.currentSkewValue(cs); var sampleCount = (cs.samples || []).length; var sevLabel = SKEW_SEVERITY_LABELS[cs.severity] || cs.severity; var reasonLine = '
Recent ' + sampleCount + ' adverts median ' + formatSkew(skewVal) + ' → ' + sevLabel + '
'; var hashBlocks = evidence.map(function(ev) { var shortHash = (ev.hash || '').substring(0, 8) + '…'; var obsCount = ev.observers ? ev.observers.length : 0; // #1285: per-hash median is server-side filtered to exclude RTC-reset // outliers (|corrected skew| > 24h). Compute the same on the client so // we can label hashes whose observers ALL saw a reset-shaped advert as // "insufficient data — N outliers excluded" instead of rendering 0 or // a misleading post-filter value. var OUTLIER_SEC = 86400; var outlierObs = 0; (ev.observers || []).forEach(function(o) { if (Math.abs(o.correctedSkewSec || 0) > OUTLIER_SEC) outlierObs++; }); var medianLabel; if (outlierObs > 0 && outlierObs === obsCount) { medianLabel = 'insufficient data (' + outlierObs + ' RTC-reset outlier' + (outlierObs !== 1 ? 's' : '') + ' excluded)'; } else if (outlierObs > 0) { medianLabel = formatSkew(ev.medianCorrectedSkewSec) + ' (' + outlierObs + ' RTC-reset outlier' + (outlierObs !== 1 ? 's' : '') + ' excluded)'; } else { medianLabel = formatSkew(ev.medianCorrectedSkewSec); } var header = '
Hash ' + shortHash + ' · ' + obsCount + ' observer' + (obsCount !== 1 ? 's' : '') + ' · median corrected: ' + medianLabel + '
'; var lines = (ev.observers || []).map(function(o) { var name = o.observerName || o.observerID; return '
' + name + ' raw=' + formatSkew(o.rawSkewSec) + ' corrected=' + formatSkew(o.correctedSkewSec) + ' (observer offset ' + formatSkew(o.observerOffsetSec) + ')' + '
'; }).join(''); return header + lines; }).join(''); return '
Evidence (' + evidence.length + ' hashes)' + '
' + reasonLine + calLine + hashBlocks + '
'; } async function loadClockSkewInto(container, pubkey) { if (!container) return; try { var cs = await api('/nodes/' + encodeURIComponent(pubkey) + '/clock-skew', { ttl: 30000 }); if (!cs || !cs.severity) return; container.style.display = ''; var driftHtml = cs.driftPerDaySec ? '
Drift: ' + formatDrift(cs.driftPerDaySec) + '
' : ''; var sparkHtml = renderSkewSparkline(cs.samples, 200, 32); var skewVal = window.currentSkewValue(cs); var skewDisplay = cs.severity === 'no_clock' ? 'No Clock' : '' + formatSkew(skewVal) + ''; var bimodalWarning = ''; if (cs.severity === 'bimodal_clock') { var totalRecent = cs.recentSampleCount || 0; bimodalWarning = '
⚠️ ' + (cs.recentBadSampleCount || '?') + ' of last ' + (totalRecent || '?') + ' adverts had nonsense timestamps (likely RTC reset)
'; } container.innerHTML = '

⏰ Clock Skew

' + '
' + skewDisplay + renderSkewBadge(cs.severity, skewVal, cs) + (cs.calibrated ? ' ✓ calibrated' : '') + '
' + driftHtml + (sparkHtml ? '
' + sparkHtml + '
Skew over time (' + (cs.samples || []).length + ' samples)
' : '') + bimodalWarning + buildEvidencePanel(cs); } catch (e) { // Non-fatal — section stays hidden } } /** Fetch fleet clock skew once, return map keyed by pubkey */ async function getFleetSkew() { if (_fleetSkew) return _fleetSkew; try { const data = await api('/nodes/clock-skew', { ttl: 30000 }); _fleetSkew = {}; (Array.isArray(data) ? data : []).forEach(function(cs) { if (cs && cs.pubkey) _fleetSkew[cs.pubkey] = cs; }); } catch (e) { _fleetSkew = {}; } return _fleetSkew; } // Build a map of lowercased name → count of distinct pubkeys sharing that name function buildDupNameMap(allNodes) { var map = {}; (allNodes || []).forEach(function(n) { if (!n.name) return; var key = n.name.toLowerCase(); if (!map[key]) map[key] = []; if (map[key].indexOf(n.public_key) === -1) map[key].push(n.public_key); }); return map; } function dupNameBadge(name, pubkey, dupMap) { if (!name || !dupMap) return ''; var keys = dupMap[name.toLowerCase()]; if (!keys || keys.length <= 1) return ''; var others = keys.filter(function(k) { return k !== pubkey; }); var title = keys.length + ' nodes share this name (' + others.map(function(k) { return k.slice(0, 8) + '…'; }).join(', ') + ')'; return ' (' + keys.length + ')'; } async function loadNodes(refreshOnly) { 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 ap = AreaFilter.getAreaParam(); if (ap) params.set('area', ap); const [data] = await Promise.all([ api('/nodes?' + params, { ttl: CLIENT_TTL.nodeList }), getFleetSkew() // pre-fetch clock skew in parallel ]); _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) { filtered = filtered.filter(n => window._nodesMatchesSearch(n, search)); } 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(); if (refreshOnly) { renderRows(); } else { 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.
'; } finally { // Always signal data-loaded — even on error — so E2E tests can proceed. var nodesContainer = document.getElementById('nodesLeft') || document.getElementById('nodesBody'); if (nodesContainer) nodesContainer.setAttribute('data-loaded', 'true'); } } 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 Public Key Role Scope Last Seen Adverts
`; // Tab clicks const nodeTabs = document.getElementById('nodeTabs'); initTabBar(nodeTabs); el.querySelectorAll('.node-tab').forEach(btn => { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); 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(); }); }); // Initialize TableSort on nodes table (handles header clicks, indicators, persistence) // We use onSort callback to re-render rows (sorting is done at JS-array level in renderRows // because of claimed/favorites pinning logic that TableSort can't handle) var nodesTableEl = document.getElementById('nodesTable'); if (nodesTableEl && window.TableSort) { _nodesTableSortCtrl = TableSort.init(nodesTableEl, { defaultColumn: 'last_seen', defaultDirection: 'desc', storageKey: 'meshcore-nodes-sort', onSort: function () { renderRows(); updateNodesUrl(); } }); } // 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; history.replaceState(null, '', '#/nodes'); renderRows(); } } }); // #630: Close button for node detail panel (important for mobile full-screen overlay) document.getElementById('nodesRight').addEventListener('click', function(e) { // #778/#856: Analytics link — force hashchange via replaceState + assign. // (Details button is handled separately via .node-detail-btn click listener) var link = e.target.closest('a.btn-primary[href^="#/nodes/"]'); if (link) { e.preventDefault(); var href = link.getAttribute('href'); if (href.indexOf('/analytics') !== -1) { // Analytics link — different page, force hashchange via replaceState + assign history.replaceState(null, '', '#/'); location.hash = href.substring(1); } return; } if (e.target.closest('.panel-close-btn')) { const panel = document.getElementById('nodesRight'); panel.classList.add('empty'); panel.innerHTML = 'Select a node to view details'; selectedKey = null; history.replaceState(null, '', '#/nodes'); 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; }); const dupMap = buildDupNameMap(_allNodes); 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'; const cs = _fleetSkew && _fleetSkew[n.public_key]; const skewBadgeHtml = cs && cs.severity && cs.severity !== 'ok' ? renderSkewBadge(cs.severity, window.currentSkewValue(cs), cs) : ''; return ` ${favStar(n.public_key, 'node-fav')}${isClaimed ? ' ' : ''}${n.name || '(unnamed)'}${dupNameBadge(n.name, n.public_key, dupMap)}${skewBadgeHtml} ${truncate(n.public_key, 16)} ${n.role} ${n.default_scope ? escapeHtml(n.default_scope) : ''} ${renderNodeTimestampHtml(n.last_heard || n.last_seen)} ${n.advert_count || 0} `; }).join(''); bindFavStars(tbody); makeColumnsResizable('#nodesTable', 'meshcore-nodes-col-widths'); // #1056: fluid columns + +N hidden pill if (window.TableResponsive) { var _ndTbl = document.getElementById('nodesTable'); if (_ndTbl) window.TableResponsive.register(_ndTbl); } } /** * Navigate to the full-screen node view for `pubkey` from anywhere within * the nodes module. Single source of navigation truth — works regardless * of current hash state (hash assignment alone is a no-op when the hash * is already the target). */ function navigateToNode(pubkey) { destroy(); var appEl = document.getElementById('app'); history.replaceState(null, '', '#/nodes/' + encodeURIComponent(pubkey)); init(appEl, pubkey); } async function selectNode(pubkey) { // On mobile, navigate to full-screen node view if (window.innerWidth <= 640) { location.hash = '#/nodes/' + encodeURIComponent(pubkey); return; } // #1056 AC#4: narrow desktop/tablet (641–1023) — open detail in slide-over. if (window.SlideOver && window.SlideOver.shouldUse()) { selectedKey = pubkey; history.replaceState(null, '', '#/nodes/' + encodeURIComponent(pubkey)); renderRows(); const so = window.SlideOver.open({ title: 'Node detail', // Resolver runs after onClose re-renders rows, so look the row up // by data-key after the new tbody is in place. restoreFocus: function () { return document.querySelector('#nodesTable tbody tr[data-key="' + (window.CSS && CSS.escape ? CSS.escape(pubkey) : pubkey) + '"]'); }, onClose: function () { selectedKey = null; history.replaceState(null, '', '#/nodes'); renderRows(); } }); so.innerHTML = '
Loading…
'; try { const data = await fetchNodeDetail(pubkey); if (selectedKey !== pubkey) return; const n = (data && data.node) || data || {}; const titleEl = document.querySelector('.slide-over-title'); if (titleEl) titleEl.textContent = n.advert_name || (n.public_key ? n.public_key.slice(0, 10) : 'Node'); var role = (n.role || '').toString(); var lastHeard = n.last_heard || n.last_seen; so.innerHTML = '
' + '
Name
' + escapeHtml(n.advert_name || '—') + '
' + '
Role
' + escapeHtml(role || '—') + '
' + '
Public key
' + escapeHtml(n.public_key || '—') + '
' + '
Last heard
' + (lastHeard ? timeAgo(lastHeard) : '—') + '
' + '
Adverts
' + (n.advert_count != null ? n.advert_count : '—') + '
' + '
' + '

Open full detail →

'; } catch (e) { so.innerHTML = '
Error: ' + (e && e.message ? e.message : String(e)) + '
'; } return; } selectedKey = pubkey; history.replaceState(null, '', '#/nodes/' + encodeURIComponent(pubkey)); renderRows(); const panel = document.getElementById('nodesRight'); panel.classList.remove('empty'); panel.innerHTML = '
Loading…
'; try { const data = await fetchNodeDetail(pubkey); 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; const dupMap = buildDupNameMap(_allNodes); const dupBadge = dupNameBadge(n.name, n.public_key, dupMap); panel.innerHTML = `
${escapeHtml(n.name || '(unnamed)')}${dupBadge}
${renderNodeBadges(n, roleColor)} 📊 Analytics
${renderStatusExplanation(n)} ${hasLoc ? `
` : `
`}
${n.public_key}

Overview

Last Heard
${renderNodeTimestampHtml(lastHeard || n.last_seen)}
First Seen
${renderNodeTimestampHtml(n.first_seen)}
Total Packets
${totalPackets}
Packets Today
${stats.packetsToday || 0}
${stats.avgSnr != null ? `
Avg SNR
${Number(stats.avgSnr).toFixed(1)} dB
` : ''} ${stats.avgHops ? `
Avg Hops
${stats.avgHops}
` : ''} ${hasLoc ? `
Location
${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}
` : ''}
${(() => { const validPackets = adverts.filter(a => a.hash && a.timestamp); return `

Recent Packets (${validPackets.length})

${validPackets.length ? validPackets.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
'}
`; })()}
${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 => { const stats = [`${o.packetCount} pkts`]; if (o.avgSnr != null) stats.push('SNR ' + Number(o.avgSnr).toFixed(1) + 'dB'); if (o.avgRssi != null) stats.push('RSSI ' + Number(o.avgRssi).toFixed(0)); return `
${escapeHtml(o.observer_name || o.observer_id)}${o.iata ? ' ' + escapeHtml(o.iata) + '' : ''} ${stats.join(' · ')}
`; }).join('')}
` : ''}

Neighbors

Loading neighbors…

Paths Through This Node

Loading paths…
`; // 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 {} } // Wire "Details" button via the unified navigateToNode helper var detailBtn = panel.querySelector('.node-detail-btn'); if (detailBtn) { detailBtn.addEventListener('click', function() { navigateToNode(decodeURIComponent(detailBtn.getAttribute('data-pubkey'))); }); } // Fetch neighbors for this node (condensed panel — top 5) fetchAndRenderNeighbors(n.public_key, 'panelNeighborsContent', { limit: 5, headerSelector: '#panelNeighborsHeader', viewAllPubkey: n.public_key }); // #813 — Clock Skew section in side panel (mirrors full-screen view) loadClockSkewInto(document.getElementById('node-clock-skew'), n.public_key); // 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 ${renderNodeTimestampHtml(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
'; }); } function isAdvertMessage(m) { if (m.type !== 'packet') return false; if (m.data && m.data.packet && m.data.packet.payload_type === 4) return true; if (m.data && m.data.decoded && m.data.decoded.header && m.data.decoded.header.payloadTypeName === 'ADVERT') return true; return false; } registerPage('nodes', { init: function(app, routeParam) { _themeRefreshHandler = () => { if (directNode) loadFullNode(directNode); else { renderRows(); if (selectedKey) selectNode(selectedKey); } }; window.addEventListener('theme-refresh', _themeRefreshHandler); return init(app, routeParam); }, destroy: function() { if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } return destroy(); } }); // Test hooks window._nodesIsAdvertMessage = isAdvertMessage; window._nodesGetAllNodes = function() { return _allNodes; }; window._nodesSetAllNodes = function(n) { _allNodes = n; }; window._nodesToggleSort = function(col) { if (_nodesTableSortCtrl) { _nodesTableSortCtrl.sort(col); return; } // Fallback for tests without DOM var st = _getSortState(); var descDefault = ['last_seen', 'advert_count']; if (st.column === col) { _fallbackSortState = { column: col, direction: st.direction === 'asc' ? 'desc' : 'asc' }; } else { _fallbackSortState = { column: col, direction: descDefault.indexOf(col) >= 0 ? 'desc' : 'asc' }; } localStorage.setItem('meshcore-nodes-sort', JSON.stringify(_fallbackSortState)); }; window._nodesSortNodes = sortNodes; window._nodesSortArrow = function(col) { var st = _getSortState(); if (st.column !== col) return ''; return '' + (st.direction === 'asc' ? '▲' : '▼') + ''; }; window._nodesGetSortState = _getSortState; window._nodesSetSortState = function(s) { _fallbackSortState = s; if (_nodesTableSortCtrl) _nodesTableSortCtrl.sort(s.column, s.direction); }; window._nodesSyncClaimedToFavorites = syncClaimedToFavorites; window._nodesRenderNodeTimestampHtml = renderNodeTimestampHtml; window._nodesRenderNodeTimestampText = renderNodeTimestampText; window._nodesGetStatusInfo = getStatusInfo; window._nodesGetStatusTooltip = getStatusTooltip; // #862: Expose search filter logic for testing window._nodesMatchesSearch = function(node, query) { if (!query) return true; var q = query.toLowerCase(); var isHex = /^[0-9a-f]+$/i.test(q); if ((node.name || '').toLowerCase().includes(q)) return true; if (isHex && (node.public_key || '').toLowerCase().startsWith(q)) return true; return false; }; })();