Files
meshcore-analyzer/public/nodes.js

537 lines
25 KiB
JavaScript

/* === 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 = '';
let sortBy = 'lastSeen';
let lastHeard = '';
let wsHandler = null;
let detailMap = null;
const ROLE_COLORS = { repeater: '#3b82f6', room: '#6b7280', companion: '#22c55e', sensor: '#f59e0b' };
const TABS = [
{ key: 'all', label: 'All' },
{ key: 'repeater', label: 'Repeaters' },
{ key: 'room', label: 'Rooms' },
{ key: 'companion', label: 'Companions' },
{ key: 'sensor', label: 'Sensors' },
];
let directNode = null; // set when navigating directly to #/nodes/:pubkey
function init(app, routeParam) {
directNode = routeParam || null;
if (directNode) {
// Full-screen single node view
app.innerHTML = `<div class="node-fullscreen">
<div class="node-full-header">
<button class="detail-back-btn node-back-btn" id="nodeBackBtn" aria-label="Back to nodes">←</button>
<span class="node-full-title">Loading…</span>
</div>
<div class="node-full-body" id="nodeFullBody">
<div class="text-center text-muted" style="padding:40px">Loading…</div>
</div>
</div>`;
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 = `<div class="nodes-page">
<div class="nodes-topbar">
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search nodes by name…" aria-label="Search nodes by name">
<div class="nodes-counts" id="nodeCounts"></div>
</div>
<div class="split-layout">
<div class="panel-left" id="nodesLeft"></div>
<div class="panel-right empty" id="nodesRight"><span>Select a node to view details</span></div>
</div>
</div>`;
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)),
api('/nodes/' + encodeURIComponent(pubkey) + '/health').catch(() => null)
]);
const n = nodeData.node;
const adverts = nodeData.recentAdverts || [];
const title = document.querySelector('.node-full-title');
if (title) title.textContent = n.name || pubkey.slice(0, 12);
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
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;
const statusAge = lastHeard ? (Date.now() - new Date(lastHeard).getTime()) : Infinity;
// Thresholds based on MeshCore advert intervals:
// Repeaters/rooms: flood advert every 12-24h, so degraded after 24h, silent after 72h
// Companions/sensors: user-initiated adverts, shorter thresholds
const role = (n.role || '').toLowerCase();
const isInfra = role === 'repeater' || role === 'room';
const degradedMs = isInfra ? 86400000 : 3600000; // 24h : 1h
const silentMs = isInfra ? 259200000 : 86400000; // 72h : 24h
const statusLabel = statusAge < degradedMs ? '🟢 Active' : statusAge < silentMs ? '🟡 Degraded' : '🔴 Silent';
body.innerHTML = `
${hasLoc ? `<div id="nodeFullMap" class="node-detail-map" style="border-radius:8px;overflow:hidden;margin-bottom:16px"></div>` : ''}
<div class="node-full-card">
<div class="node-detail-name" style="font-size:20px">${escapeHtml(n.name || '(unnamed)')}</div>
<div style="margin:6px 0 12px"><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span> ${statusLabel}</div>
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:8px">${n.public_key}</div>
<div style="margin-bottom:12px">
<button class="btn-primary" id="copyUrlBtn" style="font-size:12px;padding:4px 10px">📋 Copy URL</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:6px;text-decoration:none;font-size:12px;padding:4px 10px">📊 Analytics</a>
</div>
<div class="node-qr" id="nodeFullQrCode"></div>
</div>
<div class="node-full-card">
<h4>Stats</h4>
<dl class="detail-meta">
<dt>Last Heard</dt><dd>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}</dd>
<dt>First Seen</dt><dd>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</dd>
<dt>Total Packets</dt><dd>${stats.totalPackets || n.advert_count || 0}</dd>
<dt>Packets Today</dt><dd>${stats.packetsToday || 0}</dd>
${stats.avgSnr != null ? `<dt>Avg SNR</dt><dd>${stats.avgSnr.toFixed(1)} dB</dd>` : ''}
${stats.avgHops ? `<dt>Avg Hops</dt><dd>${stats.avgHops}</dd>` : ''}
${hasLoc ? `<dt>Location</dt><dd>${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}</dd>` : ''}
</dl>
</div>
${observers.length ? `<div class="node-full-card">
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
<table class="data-table" style="font-size:12px">
<thead><tr><th>Observer</th><th>Packets</th><th>Avg SNR</th><th>Avg RSSI</th></tr></thead>
<tbody>
${observers.map(o => `<tr>
<td style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</td>
<td>${o.packetCount}</td>
<td>${o.avgSnr != null ? o.avgSnr.toFixed(1) + ' dB' : '—'}</td>
<td>${o.avgRssi != null ? o.avgRssi.toFixed(0) + ' dBm' : '—'}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : ''}
<div class="node-full-card">
<h4>Recent Packets (${adverts.length})</h4>
<div class="node-activity-list">
${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` : '';
return `<div class="node-activity-item">
<span class="node-activity-time">${timeAgo(p.timestamp)}</span>
<span>${typeLabel}${detail}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
<a href="#/packets/id/${p.id}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze </a>
</div>`;
}).join('') : '<div class="text-muted">No recent packets</div>'}
</div>
</div>`;
// 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(() => {});
});
// 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 = `<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Scan with MeshCore app to add contact</div>` + qr.createSvgTag(3, 0);
const svg = qrFullEl.querySelector('svg');
if (svg) { svg.style.display = 'block'; svg.style.margin = '0 auto'; }
} catch {}
}
} catch (e) {
body.innerHTML = `<div class="text-muted" style="padding:40px">Failed to load node: ${e.message}</div>`;
}
}
function destroy() {
if (wsHandler) offWS(wsHandler);
wsHandler = null;
if (detailMap) { detailMap.remove(); detailMap = null; }
nodes = [];
selectedKey = null;
}
async function loadNodes() {
try {
const params = new URLSearchParams({ limit: '200', sortBy });
if (activeTab !== 'all') params.set('role', activeTab);
if (search) params.set('search', search);
if (lastHeard) params.set('lastHeard', lastHeard);
const data = await api('/nodes?' + params);
nodes = data.nodes || [];
counts = data.counts || {};
// 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)))
);
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 = '<tr><td colspan="6" class="text-center" style="padding:24px;color:var(--error,#ef4444)"><div role="alert" aria-live="polite">Failed to load nodes. Please try again.</div></td></tr>';
}
}
function renderCounts() {
const el = document.getElementById('nodeCounts');
if (!el) return;
el.innerHTML = [
{ k: 'repeaters', l: 'Repeaters', c: '#3b82f6' },
{ k: 'rooms', l: 'Rooms', c: '#6b7280' },
{ k: 'companions', l: 'Companions', c: '#22c55e' },
{ k: 'sensors', l: 'Sensors', c: '#f59e0b' },
].map(r => `<span class="node-count-pill" style="background:${r.c}">${counts[r.k] || 0} ${r.l}</span>`).join('');
}
function renderLeft() {
const el = document.getElementById('nodesLeft');
if (!el) return;
el.innerHTML = `
<div class="nodes-tabs-bar">
<div class="nodes-tabs" id="nodeTabs">
${TABS.map(t => `<button class="node-tab ${activeTab === t.key ? 'active' : ''}" data-tab="${t.key}">${t.label}</button>`).join('')}
</div>
<div class="nodes-filters">
<select id="nodeLastHeard" aria-label="Filter by last heard time">
<option value="">Last Heard: Any</option>
<option value="1h" ${lastHeard==='1h'?'selected':''}>1 hour</option>
<option value="6h" ${lastHeard==='6h'?'selected':''}>6 hours</option>
<option value="24h" ${lastHeard==='24h'?'selected':''}>24 hours</option>
<option value="7d" ${lastHeard==='7d'?'selected':''}>7 days</option>
<option value="30d" ${lastHeard==='30d'?'selected':''}>30 days</option>
</select>
<select id="nodeSort" aria-label="Sort nodes">
<option value="lastSeen" ${sortBy==='lastSeen'?'selected':''}>Sort: Last Seen</option>
<option value="name" ${sortBy==='name'?'selected':''}>Sort: Name</option>
<option value="packetCount" ${sortBy==='packetCount'?'selected':''}>Sort: Adverts</option>
</select>
</div>
</div>
<table class="data-table" id="nodesTable">
<thead><tr>
<th class="sortable" data-sort="name" aria-sort="${sortBy === 'name' ? 'ascending' : 'none'}">Name</th>
<th>Public Key</th>
<th>Role</th>
<th class="sortable" data-sort="lastSeen" aria-sort="${sortBy === 'lastSeen' ? 'descending' : 'none'}">Last Seen</th>
<th class="sortable" data-sort="packetCount" aria-sort="${sortBy === 'packetCount' ? 'descending' : 'none'}">Adverts</th>
</tr></thead>
<tbody id="nodesBody"></tbody>
</table>`;
// 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; loadNodes(); });
document.getElementById('nodeSort').addEventListener('change', e => { sortBy = e.target.value; loadNodes(); });
// Sortable column headers
el.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => { sortBy = th.dataset.sort; loadNodes(); });
});
// 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 = '<span>Select a node to view details</span>';
selectedKey = null;
renderRows();
}
}
});
renderRows();
}
function renderRows() {
const tbody = document.getElementById('nodesBody');
if (!tbody) return;
if (!nodes.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted" style="padding:24px">No nodes found</td></tr>';
return;
}
// Claimed ("My Mesh") nodes always on top, then favorites
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
const myKeys = new Set(myNodes.map(n => n.pubkey));
const favs = getFavorites();
const sorted = [...nodes].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);
return `<tr data-key="${n.public_key}" data-action="select" data-value="${n.public_key}" tabindex="0" role="row" class="${selectedKey === n.public_key ? 'selected' : ''}${isClaimed ? ' claimed-row' : ''}">
<td>${favStar(n.public_key, 'node-fav')}${isClaimed ? '<span class="claimed-badge" title="My Mesh">★</span> ' : ''}<strong>${n.name || '(unnamed)'}</strong></td>
<td class="mono">${truncate(n.public_key, 16)}</td>
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
<td>${timeAgo(n.last_seen)}</td>
<td>${n.advert_count || 0}</td>
</tr>`;
}).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 = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
try {
const [data, healthData] = await Promise.all([
api('/nodes/' + encodeURIComponent(pubkey)),
api('/nodes/' + encodeURIComponent(pubkey) + '/health').catch(() => null)
]);
data.healthData = healthData;
renderDetail(panel, data);
} catch (e) {
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
}
}
function renderDetail(panel, data) {
const n = data.node;
const adverts = data.recentAdverts || [];
const h = data.healthData || {};
const stats = h.stats || {};
const observers = h.observers || [];
const recent = h.recentPackets || [];
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
const hasLoc = n.lat != null && n.lon != null;
const nodeUrl = location.origin + '#/nodes/' + encodeURIComponent(n.public_key);
// Status calculation
const lastHeard = stats.lastHeard;
const statusAge = lastHeard ? (Date.now() - new Date(lastHeard).getTime()) : Infinity;
const role = (n.role || '').toLowerCase();
const isInfra = role === 'repeater' || role === 'room';
const degradedMs = isInfra ? 86400000 : 3600000;
const silentMs = isInfra ? 259200000 : 86400000;
const statusLabel = statusAge < degradedMs ? '🟢 Active' : statusAge < silentMs ? '🟡 Degraded' : '🔴 Silent';
const totalPackets = stats.totalPackets || n.advert_count || 0;
panel.innerHTML = `
<div class="node-detail">
${hasLoc ? `<div class="node-map-container node-detail-map" id="nodeMap" style="border-radius:8px;overflow:hidden;"></div>` : ''}
<div class="node-detail-name">${escapeHtml(n.name || '(unnamed)')}</div>
<div class="node-detail-role"><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span> ${statusLabel}
<button class="btn-primary" id="copyUrlBtn" style="font-size:11px;padding:2px 8px;margin-left:8px">📋 URL</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px">📊 Analytics</a>
</div>
<div class="node-detail-section">
<h4>Public Key</h4>
<div class="node-detail-key mono">${n.public_key}</div>
<div class="node-qr" id="nodeQrCode"></div>
</div>
<div class="node-detail-section">
<h4>Overview</h4>
<dl class="detail-meta">
<dt>Last Heard</dt><dd>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}</dd>
<dt>First Seen</dt><dd>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</dd>
<dt>Total Packets</dt><dd>${totalPackets}</dd>
<dt>Packets Today</dt><dd>${stats.packetsToday || 0}</dd>
${stats.avgSnr != null ? `<dt>Avg SNR</dt><dd>${stats.avgSnr.toFixed(1)} dB</dd>` : ''}
${stats.avgHops ? `<dt>Avg Hops</dt><dd>${stats.avgHops}</dd>` : ''}
${hasLoc ? `<dt>Location</dt><dd>${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}</dd>` : ''}
</dl>
</div>
${observers.length ? `<div class="node-detail-section">
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
<div class="observer-list">
${observers.map(o => `<div class="observer-row" style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px">
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</span>
<span style="color:var(--text-muted)">${o.packetCount} pkts · ${o.avgSnr != null ? 'SNR ' + o.avgSnr.toFixed(1) + 'dB' : ''}${o.avgRssi != null ? ' · RSSI ' + o.avgRssi.toFixed(0) : ''}</span>
</div>`).join('')}
</div>
</div>` : ''}
<div class="node-detail-section">
<h4>Recent Packets (${adverts.length})</h4>
<div id="advertTimeline">
${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 `<div class="advert-entry">
<span class="advert-dot" style="background:${roleColor}"></span>
<div class="advert-info">
<strong>${timeAgo(a.timestamp)}</strong> ${icon} ${pType}${detail}
${obs ? ' via ' + escapeHtml(obs) : ''}
${a.snr != null ? ` · SNR ${a.snr}dB` : ''}${a.rssi != null ? ` · RSSI ${a.rssi}dBm` : ''}
<br><a href="#/packets/id/${a.id}" class="ch-analyze-link">Analyze </a>
</div>
</div>`;
}).join('') : '<div class="text-muted" style="padding:8px">No recent packets</div>'}
</div>
</div>
</div>`;
// 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();
qrEl.innerHTML = `<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Scan with MeshCore app to add contact</div>` + qr.createSvgTag(3, 0);
const svg = qrEl.querySelector('svg');
if (svg) { svg.style.display = 'block'; svg.style.margin = '0 auto'; }
} catch {}
}
// Copy URL
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(() => {});
});
}
registerPage('nodes', { init, destroy });
})();