diff --git a/public/app.js b/public/app.js index f33a0201..1156dff1 100644 --- a/public/app.js +++ b/public/app.js @@ -88,11 +88,53 @@ window.apiPerf = function() { function timeAgo(iso) { if (!iso) return '—'; - const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); - if (s < 60) return s + 's ago'; - if (s < 3600) return Math.floor(s / 60) + 'm ago'; - if (s < 86400) return Math.floor(s / 3600) + 'h ago'; - return Math.floor(s / 86400) + 'd ago'; + const ms = new Date(iso).getTime(); + if (!isFinite(ms)) return '—'; + const s = Math.floor((Date.now() - ms) / 1000); + const abs = Math.abs(s); + let value; + let suffix; + if (abs < 60) { value = abs; suffix = 's'; } + else if (abs < 3600) { value = Math.floor(abs / 60); suffix = 'm'; } + else if (abs < 86400) { value = Math.floor(abs / 3600); suffix = 'h'; } + else { value = Math.floor(abs / 86400); suffix = 'd'; } + if (s < 0) return 'in ' + value + suffix; + return value + suffix + ' ago'; +} + +function getTimestampMode() { + const saved = localStorage.getItem('meshcore-timestamp-mode'); + if (saved === 'ago' || saved === 'absolute') return saved; + const serverDefault = window.SITE_CONFIG?.timestamps?.defaultMode; + return serverDefault === 'absolute' ? 'absolute' : 'ago'; +} + +function formatAbsoluteTimestamp(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (!isFinite(d.getTime())) return '—'; + const mobileShort = typeof window !== 'undefined' && window.innerWidth <= 640; + if (mobileShort) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + return d.toISOString(); +} + +function formatTimestamp(isoString, mode) { + return formatTimestampWithTooltip(isoString, mode).text; +} + +function formatTimestampWithTooltip(isoString, mode) { + if (!isoString) return { text: '—', tooltip: '—', isFuture: false }; + const d = new Date(isoString); + if (!isFinite(d.getTime())) return { text: '—', tooltip: '—', isFuture: false }; + const activeMode = mode === 'absolute' || mode === 'ago' ? mode : getTimestampMode(); + const isFuture = d.getTime() > Date.now(); + const absolute = formatAbsoluteTimestamp(isoString); + const relative = timeAgo(isoString); + const text = isFuture ? absolute : (activeMode === 'absolute' ? absolute : relative); + const tooltip = isFuture ? relative : (activeMode === 'absolute' ? relative : absolute); + return { text, tooltip, isFuture }; } function truncate(str, len) { @@ -347,6 +389,9 @@ window.addEventListener('theme-changed', () => { window.dispatchEvent(new CustomEvent('theme-refresh')); }, 300); }); +window.addEventListener('timestamp-mode-changed', () => { + window.dispatchEvent(new CustomEvent('theme-refresh')); +}); window.addEventListener('DOMContentLoaded', () => { connectWS(); @@ -603,7 +648,12 @@ window.addEventListener('DOMContentLoaded', () => { // --- Theme Customization --- // Fetch theme config and apply branding/colors before first render fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => { - window.SITE_CONFIG = cfg; + window.SITE_CONFIG = cfg || {}; + if (!window.SITE_CONFIG.timestamps) { + window.SITE_CONFIG.timestamps = { defaultMode: 'ago' }; + } else if (window.SITE_CONFIG.timestamps.defaultMode !== 'absolute' && window.SITE_CONFIG.timestamps.defaultMode !== 'ago') { + window.SITE_CONFIG.timestamps.defaultMode = 'ago'; + } // User's localStorage preferences take priority over server config const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })(); @@ -677,7 +727,7 @@ window.addEventListener('DOMContentLoaded', () => { if (favicon) favicon.href = cfg.branding.faviconUrl; } } - }).catch(() => { window.SITE_CONFIG = null; }).finally(() => { + }).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago' } }; }).finally(() => { if (!location.hash || location.hash === '#/') location.hash = '#/home'; else navigate(); }); diff --git a/public/customize.js b/public/customize.js index 1a4d5701..709724ad 100644 --- a/public/customize.js +++ b/public/customize.js @@ -63,6 +63,9 @@ { label: '📡 All Nodes', url: '#/nodes' }, { label: '💬 Channels', url: '#/channels' } ] + }, + ui: { + timestampMode: 'ago' } }; @@ -441,6 +444,12 @@ // Merge: DEFAULTS → server config → localStorage saved values var local = {}; try { var s = localStorage.getItem('meshcore-user-theme'); if (s) local = JSON.parse(s); } catch {} + var localTsMode = localStorage.getItem('meshcore-timestamp-mode'); + var serverTsMode = (cfg.timestamps && cfg.timestamps.defaultMode === 'absolute') ? 'absolute' : 'ago'; + var mergedUi = Object.assign({}, DEFAULTS.ui, cfg.ui || {}, local.ui || {}); + mergedUi.timestampMode = (localTsMode === 'ago' || localTsMode === 'absolute') + ? localTsMode + : (mergedUi.timestampMode === 'absolute' || serverTsMode === 'absolute' ? 'absolute' : 'ago'); state = { branding: Object.assign({}, DEFAULTS.branding, cfg.branding || {}, local.branding || {}), theme: Object.assign({}, DEFAULTS.theme, cfg.theme || {}, local.theme || {}), @@ -453,7 +462,8 @@ steps: deepClone((local.home && local.home.steps) || (cfg.home && cfg.home.steps) || DEFAULTS.home.steps), checklist: deepClone((local.home && local.home.checklist) || (cfg.home && cfg.home.checklist) || DEFAULTS.home.checklist), footerLinks: deepClone((local.home && local.home.footerLinks) || (cfg.home && cfg.home.footerLinks) || DEFAULTS.home.footerLinks) - } + }, + ui: mergedUi }; } @@ -641,11 +651,21 @@ function renderBranding() { var b = state.branding; var logoPreview = b.logoUrl ? 'Logo preview' : ''; + var tsMode = state.ui.timestampMode === 'absolute' ? 'absolute' : 'ago'; return '
' + '
' + '
' + '
' + logoPreview + '
' + '
' + + '
' + + '

UI Settings

' + + '

Global setting — applies to all pages.

' + + '
' + + '' + + '
' + '
'; } @@ -851,6 +871,11 @@ if (JSON.stringify(state.home.footerLinks) !== JSON.stringify(DEFAULTS.home.footerLinks)) hm.footerLinks = state.home.footerLinks; if (Object.keys(hm).length) out.home = hm; + // UI + var ui = {}; + if ((state.ui.timestampMode || 'ago') !== DEFAULTS.ui.timestampMode) ui.timestampMode = state.ui.timestampMode; + if (Object.keys(ui).length) out.ui = ui; + return out; } @@ -938,6 +963,22 @@ }); }); + // UI settings + container.querySelectorAll('select[data-ui]').forEach(function (sel) { + sel.addEventListener('change', function () { + var key = sel.dataset.ui; + state.ui[key] = sel.value; + if (key === 'timestampMode') { + localStorage.setItem('meshcore-timestamp-mode', sel.value); + if (!window.SITE_CONFIG) window.SITE_CONFIG = {}; + if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {}; + window.SITE_CONFIG.timestamps.defaultMode = sel.value; + window.dispatchEvent(new CustomEvent('timestamp-mode-changed')); + } + autoSave(); + }); + }); + // Theme color pickers container.querySelectorAll('input[data-theme]').forEach(function (inp) { inp.addEventListener('input', function () { diff --git a/public/index.html b/public/index.html index ca70a70e..db014cc8 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -81,30 +81,30 @@
- - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/live.js b/public/live.js index 125686f1..5bb267c1 100644 --- a/public/live.js +++ b/public/live.js @@ -25,6 +25,7 @@ let _lcdClockInterval = null; let _rateCounterInterval = null; let _pruneInterval = null; + let activeNodeDetailKey = null; // === VCR State Machine === const VCR = { @@ -51,6 +52,19 @@ REQUEST: '❓', RESPONSE: '📨', TRACE: '🔍', PATH: '🛤️' }; + function formatLiveTimestampHtml(isoLike) { + if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') { + return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoLike) : '—'); + } + const d = isoLike ? new Date(isoLike) : null; + const iso = d && isFinite(d.getTime()) ? d.toISOString() : null; + const f = formatTimestampWithTooltip(iso, getTimestampMode()); + const warn = f.isFuture + ? ' ⚠️' + : ''; + return `${escapeHtml(f.text)}${warn}`; + } + function initResizeHandler() { let resizeTimer = null; _onResize = function() { @@ -935,6 +949,7 @@ const nodeDetailPanel = document.getElementById('liveNodeDetail'); const nodeDetailContent = document.getElementById('nodeDetailContent'); document.getElementById('nodeDetailClose').addEventListener('click', () => { + activeNodeDetailKey = null; nodeDetailPanel.classList.add('hidden'); }); @@ -1159,6 +1174,7 @@ } async function showNodeDetail(pubkey) { + activeNodeDetailKey = pubkey; const panel = document.getElementById('liveNodeDetail'); const content = document.getElementById('nodeDetailContent'); panel.classList.remove('hidden'); @@ -1176,7 +1192,7 @@ const roleColor = ROLE_COLORS[n.role] || '#6b7280'; const roleLabel = (ROLE_LABELS[n.role] || n.role || 'unknown').replace(/s$/, ''); const hasLoc = n.lat != null && n.lon != null; - const lastSeen = n.last_seen ? timeAgo(n.last_seen) : '—'; + const lastSeen = formatLiveTimestampHtml(n.last_seen); const thresholds = window.getHealthThresholds ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 }; const ageMs = n.last_seen ? Date.now() - new Date(n.last_seen).getTime() : Infinity; const statusDot = ageMs < thresholds.degradedMs ? 'health-green' : ageMs < thresholds.silentMs ? 'health-yellow' : 'health-red'; @@ -1217,7 +1233,7 @@
` + recent.slice(0, 10).map(p => `
${escapeHtml(p.payload_type || '?')}${p.observation_count > 1 ? ' 👁 ' + p.observation_count + '' : ''} - ${p.timestamp ? timeAgo(p.timestamp) : '—'} + ${formatLiveTimestampHtml(p.timestamp)}
`).join('') + '
'; } @@ -1403,7 +1419,7 @@ ${typeName} ${hopStr}${obsBadge} ${escapeHtml(preview)} - ${new Date(group.latestTs || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})} + ${formatLiveTimestampHtml(group.latestTs || Date.now())} `; item.addEventListener('click', () => showFeedCard(item, pkt, color)); feed.appendChild(item); @@ -2267,7 +2283,7 @@ ${typeName} ${hopStr}${obsBadge} ${escapeHtml(preview)} - ${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})} + ${formatLiveTimestampHtml(pkt._ts || Date.now())} `; item.addEventListener('click', () => showFeedCard(item, pkt, color)); feed.appendChild(item); @@ -2335,7 +2351,7 @@ ${typeName} ${hopStr}${obsBadge} ${escapeHtml(preview)} - ${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})} + ${formatLiveTimestampHtml(pkt._ts || Date.now())} `; item.addEventListener('click', () => showFeedCard(item, pkt, color)); feed.prepend(item); @@ -2438,6 +2454,7 @@ nodesLayer = pathsLayer = animLayer = heatLayer = geoFilterLayer = null; stopMatrixRain(); nodeMarkers = {}; nodeData = {}; + activeNodeDetailKey = null; recentPaths = []; packetCount = 0; activeAnims = 0; nodeActivity = {}; pktTimestamps = []; @@ -2449,7 +2466,10 @@ registerPage('live', { init: function(app, routeParam) { - _themeRefreshHandler = () => { /* live map rebuilds on next packet */ }; + _themeRefreshHandler = () => { + rebuildFeedList(); + if (activeNodeDetailKey) showNodeDetail(activeNodeDetailKey); + }; window.addEventListener('theme-refresh', _themeRefreshHandler); return init(app, routeParam); }, diff --git a/public/nodes.js b/public/nodes.js index 4370d3ad..a978ae24 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -85,6 +85,24 @@ { key: 'sensor', label: 'Sensors' }, ]; + 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) { @@ -117,7 +135,7 @@ let explanation = ''; if (status === 'active') { - explanation = 'Last heard ' + (lastHeardTime ? timeAgo(lastHeardTime) : 'unknown'); + explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown'); } else { const ageDays = Math.floor(statusAge / 86400000); const ageHours = Math.floor(statusAge / 3600000); @@ -274,8 +292,8 @@ - - + + ${stats.avgSnr != null ? `` : ''} @@ -327,7 +345,7 @@ hashSizeBadge = ` ${hs}B`; } return `
- ${timeAgo(p.timestamp)} + ${renderNodeTimestampHtml(p.timestamp)} ${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi} Analyze →
`; @@ -406,7 +424,7 @@ }).join(' → '); return `
${chain}
-
${p.count}× · last ${timeAgo(p.lastSeen)} · Analyze →
+
${p.count}× · last ${renderNodeTimestampHtml(p.lastSeen)} · Analyze →
`; }).join(''); } @@ -429,7 +447,7 @@ } } - function destroy() { + function destroy() { if (wsHandler) offWS(wsHandler); wsHandler = null; if (detailMap) { detailMap.remove(); detailMap = null; } @@ -439,6 +457,8 @@ selectedKey = null; } + let _themeRefreshHandler = null; + let _allNodes = null; // cached full node list // Build a map of lowercased name → count of distinct pubkeys sharing that name @@ -677,7 +697,7 @@ - + `; }).join(''); @@ -750,8 +770,8 @@

Overview

-
Last Heard
${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}
-
First Seen
${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}
+
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
` : ''} @@ -789,7 +809,7 @@ return `
- ${timeAgo(a.timestamp)} ${icon} ${pType}${detail} + ${renderNodeTimestampHtml(a.timestamp)} ${icon} ${pType}${detail} ${a.observation_count > 1 ? ' 👁 ' + a.observation_count + '' : ''} ${obs ? ' via ' + escapeHtml(obs) : ''} ${a.snr != null ? ` · SNR ${a.snr}dB` : ''}${a.rssi != null ? ` · RSSI ${a.rssi}dBm` : ''} @@ -863,7 +883,7 @@ }).join(' → '); return `
${chain}
-
${p.count}× · last ${timeAgo(p.lastSeen)} · Analyze →
+
${p.count}× · last ${renderNodeTimestampHtml(p.lastSeen)} · Analyze →
`; }).join(''); } @@ -889,7 +909,23 @@ return false; } - registerPage('nodes', { init, destroy }); + 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; diff --git a/public/packets.js b/public/packets.js index b379b97d..c6085c1f 100644 --- a/public/packets.js +++ b/public/packets.js @@ -160,6 +160,17 @@ let directObsId = null; + function renderTimestampCell(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}`; + } + async function init(app, routeParam) { const gen = ++initGeneration; // Parse ?obs=OBSERVER_ID from routeParam @@ -1024,7 +1035,7 @@ html += `
- + @@ -1051,7 +1062,7 @@ const childPathStr = renderPath(childPath, c.observer_id); html += ` - + @@ -1080,7 +1091,7 @@ return ` - + @@ -1330,7 +1341,7 @@
Route Type
${routeTypeName(pkt.route_type)}
Payload Type
${typeName}
${hashSize ? `
Hash Size
${hashSize} byte${hashSize !== 1 ? 's' : ''}
` : ''} -
Timestamp
${pkt.timestamp}
+
Timestamp
${renderTimestampCell(pkt.timestamp)}
Propagation
${propagationHtml}
Path
${pathHops.length ? renderPath(pathHops, pkt.observer_id) : '—'}
diff --git a/public/style.css b/public/style.css index c536024d..eb446899 100644 --- a/public/style.css +++ b/public/style.css @@ -280,6 +280,8 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible } .data-table td.col-details { white-space: normal; word-break: break-word; } .data-table td:has(.spark-bar), .data-table td.col-spark { max-width: none; overflow: visible; min-width: 80px; } +.data-table .col-time { min-width: 108px; white-space: nowrap; } +.timestamp-future-icon { margin-left: 4px; cursor: help; } .data-table tbody tr:nth-child(even) { background: var(--row-stripe); } .data-table tbody tr:hover { background: var(--row-hover); cursor: pointer; } .data-table tbody tr.selected { background: var(--selected-bg); } @@ -879,6 +881,7 @@ button.ch-item.selected { background: var(--selected-bg); } .data-table { font-size: 11px; min-width: 0; } .data-table td { padding: 5px 4px; max-width: 100px; } .data-table th { padding: 5px 4px; font-size: 10px; } + .data-table .col-time { min-width: 64px; } .panel-left { overflow-x: auto; } /* Filters: collapse on mobile */ diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index d14436e3..20f0cf65 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -104,6 +104,45 @@ console.log('\n=== app.js: timeAgo ==='); const d = new Date(Date.now() - 259200000).toISOString(); assert.strictEqual(timeAgo(d), '3d ago'); }); + test('future timestamp returns in-format', () => { + const d = new Date(Date.now() + 120000).toISOString(); + assert.strictEqual(timeAgo(d), 'in 2m'); + }); +} + +console.log('\n=== app.js: formatTimestamp / formatTimestampWithTooltip ==='); +{ + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + const formatTimestamp = ctx.formatTimestamp; + const formatTimestampWithTooltip = ctx.formatTimestampWithTooltip; + + test('formatTimestamp null returns dash', () => { + assert.strictEqual(formatTimestamp(null, 'ago'), '—'); + }); + test('formatTimestamp ago returns relative string', () => { + const d = new Date(Date.now() - 300000).toISOString(); + assert.strictEqual(formatTimestamp(d, 'ago'), '5m ago'); + }); + test('formatTimestamp absolute returns ISO string', () => { + const d = '2024-01-02T03:04:05.000Z'; + assert.strictEqual(formatTimestamp(d, 'absolute'), d); + }); + test('formatTimestampWithTooltip future returns isFuture true', () => { + const d = new Date(Date.now() + 120000).toISOString(); + const out = formatTimestampWithTooltip(d, 'ago'); + assert.strictEqual(out.isFuture, true); + assert.strictEqual(out.text, d); + assert.strictEqual(out.tooltip, 'in 2m'); + }); + test('tooltip is opposite format', () => { + const d = '2024-01-02T03:04:05.000Z'; + const ago = formatTimestampWithTooltip(d, 'ago'); + const absolute = formatTimestampWithTooltip(d, 'absolute'); + assert.strictEqual(ago.tooltip, d); + assert.ok(absolute.tooltip.endsWith('ago') || absolute.tooltip.startsWith('in ')); + }); } console.log('\n=== app.js: escapeHtml ==='); @@ -151,27 +190,7 @@ console.log('\n=== app.js: truncate ==='); // ===== NODES.JS TESTS ===== console.log('\n=== nodes.js: getStatusInfo ==='); { - const ctx = makeSandbox(); - loadInCtx(ctx, 'public/roles.js'); - // nodes.js is an IIFE that registers a page — we need to mock registerPage and other globals - ctx.registerPage = () => {}; - ctx.api = () => Promise.resolve([]); - ctx.timeAgo = vm.runInContext(`(${fs.readFileSync('public/app.js', 'utf8').match(/function timeAgo[^}]+}/)[0]})`, ctx); - // Actually, let's load app.js first for its globals - loadInCtx(ctx, 'public/app.js'); - ctx.RegionFilter = { init: () => {}, getSelected: () => null, onRegionChange: () => {} }; - ctx.onWS = () => {}; - ctx.offWS = () => {}; - ctx.invalidateApiCache = () => {}; - ctx.favStar = () => ''; - ctx.bindFavStars = () => {}; - ctx.getFavorites = () => []; - ctx.isFavorite = () => false; - ctx.connectWS = () => {}; - loadInCtx(ctx, 'public/nodes.js'); - - // getStatusInfo is inside the IIFE, not on window. We need to extract it differently. - // Let's use a modified approach - inject a hook before loading + // Placeholder header for continuity; actual nodes tests are below using injected exports. } // Since nodes.js functions are inside an IIFE, we need to extract them.
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() : '—'}
Last Heard${renderNodeTimestampHtml(lastHeard || n.last_seen)}
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
${favStar(n.public_key, 'node-fav')}${isClaimed ? '★ ' : ''}${n.name || '(unnamed)'}${dupNameBadge(n.name, n.public_key, dupMap)} ${truncate(n.public_key, 16)} ${n.role}${timeAgo(n.last_heard || n.last_seen)}${renderNodeTimestampHtml(n.last_heard || n.last_seen)} ${n.advert_count || 0}
${isSingle ? '' : (isExpanded ? '▼' : '▶')} ${groupRegion ? `${groupRegion}` : '—'}${timeAgo(p.latest)}${renderTimestampCell(p.latest)} ${truncate(p.hash || '—', 8)} ${groupSize ? groupSize + 'B' : '—'} ${p.payload_type != null ? `${groupTypeName}` : '—'}
${childRegion ? `${childRegion}` : '—'}${timeAgo(c.timestamp)}${renderTimestampCell(c.timestamp)} ${truncate(c.hash || '', 8)} ${size}B ${typeName}
${region ? `${region}` : '—'}${timeAgo(p.timestamp)}${renderTimestampCell(p.timestamp)} ${truncate(p.hash || String(p.id), 8)} ${size}B ${typeName}