fix: analytics — async race, guards, legend CSS, dedup API, responsive layout (closes #75, #76, #77, #78, #79, #80, #81)

This commit is contained in:
you
2026-03-19 19:38:58 +00:00
parent 6fba53600f
commit b2dad0637f
4 changed files with 128 additions and 52 deletions
+16 -22
View File
@@ -121,7 +121,7 @@
}
}
function renderTab(tab) {
async function renderTab(tab) {
const el = document.getElementById('analyticsContent');
const d = _analyticsData;
switch (tab) {
@@ -130,14 +130,14 @@
case 'topology': renderTopology(el, d.topoData); break;
case 'channels': renderChannels(el, d.chanData); break;
case 'hashsizes': renderHashSizes(el, d.hashData); break;
case 'collisions': renderCollisionTab(el, d.hashData); break;
case 'subpaths': renderSubpaths(el); break;
case 'collisions': await renderCollisionTab(el, d.hashData); break;
case 'subpaths': await renderSubpaths(el); break;
}
// Auto-apply column resizing to all analytics tables
requestAnimationFrame(() => {
el.querySelectorAll('.analytics-table').forEach((tbl, i) => {
tbl.id = tbl.id || `analytics-tbl-${tab}-${i}`;
makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`);
if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`);
});
});
}
@@ -728,7 +728,7 @@
`;
}
function renderCollisionTab(el, data) {
async function renderCollisionTab(el, data) {
el.innerHTML = `
<div class="analytics-card">
<h3>1-Byte Hash Usage Matrix</h3>
@@ -741,8 +741,10 @@
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading</div></div>
</div>
`;
renderHashMatrix(data.topHops);
renderCollisions(data.topHops);
let allNodes = [];
try { const nd = await api('/nodes?limit=2000'); allNodes = nd.nodes || []; } catch {}
renderHashMatrix(data.topHops, allNodes);
renderCollisions(data.topHops, allNodes);
}
function renderHashTimeline(hourly) {
@@ -769,16 +771,9 @@
return svg;
}
async function renderHashMatrix(topHops) {
async function renderHashMatrix(topHops, allNodes) {
const el = document.getElementById('hashMatrix');
// Fetch all nodes for lookup
let allNodes = [];
try {
const nd = await api('/nodes?limit=2000');
allNodes = nd.nodes || [];
} catch {}
// Build prefix → node count map
const prefixNodes = {};
for (let i = 0; i < 256; i++) {
@@ -824,10 +819,10 @@
html += '</table></div>';
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center">
<span><span style="display:inline-block;width:12px;height:12px;background:#166534;border:1px solid var(--border);vertical-align:middle"></span> 0 — Available</span>
<span><span style="display:inline-block;width:12px;height:12px;background:#854d0e;border:1px solid var(--border);vertical-align:middle"></span> 1 — One node</span>
<span><span style="display:inline-block;width:12px;height:12px;background:rgb(200,80,30);border:1px solid var(--border);vertical-align:middle"></span> ⚠2 — Two nodes (collision)</span>
<span><span style="display:inline-block;width:12px;height:12px;background:rgb(200,0,30);border:1px solid var(--border);vertical-align:middle"></span> ⚠3+ — Three+ nodes (collision)</span>
<span><span class="legend-swatch" style="background:#166534"></span> 0 — Available</span>
<span><span class="legend-swatch" style="background:#854d0e"></span> 1 — One node</span>
<span><span class="legend-swatch" style="background:rgb(200,80,30)"></span> ⚠2 — Two nodes (collision)</span>
<span><span class="legend-swatch" style="background:rgb(200,0,30)"></span> ⚠3+ — Three+ nodes (collision)</span>
</div>`;
el.innerHTML = html;
@@ -855,13 +850,12 @@
});
}
async function renderCollisions(topHops) {
async function renderCollisions(topHops, allNodes) {
const el = document.getElementById('collisionList');
const oneByteHops = topHops.filter(h => h.size === 1);
if (!oneByteHops.length) { el.innerHTML = '<div class="text-muted">No 1-byte hops</div>'; return; }
try {
const nodesData = await api('/nodes?limit=2000');
const nodes = nodesData.nodes || [];
const nodes = allNodes;
const collisions = [];
for (const hop of oneByteHops) {
const prefix = hop.hex.toLowerCase();
+65 -9
View File
@@ -39,6 +39,7 @@
const tip = document.createElement('div');
tip.id = 'chNodeTooltip';
tip.className = 'ch-node-tooltip';
tip.setAttribute('role', 'tooltip');
const role = node.is_repeater ? '📡 Repeater' : node.is_room ? '🏠 Room' : node.is_sensor ? '🌡 Sensor' : '📻 Companion';
const lastSeen = node.last_seen ? timeAgo(node.last_seen) : 'unknown';
tip.innerHTML = `<div class="ch-tooltip-name">${escapeHtml(node.name)}</div>
@@ -46,12 +47,16 @@
<div class="ch-tooltip-meta">Last seen: ${lastSeen}</div>
<div class="ch-tooltip-key mono">${(node.public_key || '').slice(0, 16)}…</div>`;
document.body.appendChild(tip);
const rect = e.target.getBoundingClientRect();
var trigger = e.target.closest('[data-node]') || e.target;
trigger.setAttribute('aria-describedby', 'chNodeTooltip');
const rect = trigger.getBoundingClientRect();
tip.style.left = Math.min(rect.left, window.innerWidth - 220) + 'px';
tip.style.top = (rect.bottom + 4) + 'px';
}
function hideNodeTooltip() {
var trigger = document.querySelector('[aria-describedby="chNodeTooltip"]');
if (trigger) trigger.removeAttribute('aria-describedby');
const tip = document.getElementById('chNodeTooltip');
if (tip) tip.remove();
}
@@ -205,13 +210,14 @@
function init(app) {
app.innerHTML = `<div class="ch-layout">
<div class="ch-sidebar" role="navigation" aria-label="Channel list">
<div class="ch-sidebar" aria-label="Channel list">
<div class="ch-sidebar-header">
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
</div>
<div class="ch-channel-list" id="chList">
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
<div class="ch-loading">Loading channels…</div>
</div>
<div class="ch-sidebar-resize" aria-hidden="true"></div>
</div>
<div class="ch-main" role="region" aria-label="Channel messages">
<div class="ch-main-header" id="chHeader">
@@ -221,12 +227,45 @@
<div class="ch-messages" id="chMessages">
<div class="ch-empty">Choose a channel from the sidebar to view messages</div>
</div>
<button class="ch-scroll-btn hidden" id="chScrollBtn" aria-live="polite">↓ New messages</button>
<span id="chAriaLive" class="sr-only" aria-live="polite"></span>
<button class="ch-scroll-btn hidden" id="chScrollBtn">↓ New messages</button>
</div>
</div>`;
loadChannels();
// #89: Sidebar resize handle
(function () {
var sidebar = app.querySelector('.ch-sidebar');
var handle = app.querySelector('.ch-sidebar-resize');
var saved = localStorage.getItem('channels-sidebar-width');
if (saved) { var w = parseInt(saved, 10); if (w >= 180 && w <= 600) { sidebar.style.width = w + 'px'; sidebar.style.minWidth = w + 'px'; } }
var dragging = false, startX, startW;
handle.addEventListener('mousedown', function (e) { dragging = true; startX = e.clientX; startW = sidebar.getBoundingClientRect().width; e.preventDefault(); });
document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = Math.max(180, Math.min(600, startW + e.clientX - startX)); sidebar.style.width = w + 'px'; sidebar.style.minWidth = w + 'px'; });
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; localStorage.setItem('channels-sidebar-width', parseInt(sidebar.style.width, 10)); });
})();
// #90: Theme change observer — re-render messages on theme toggle
var _themeObserver = new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
if (muts[i].attributeName === 'data-theme') { if (selectedHash) renderMessages(); break; }
}
});
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// #87: Fix pointer-events during mobile slide transition
var chMain = app.querySelector('.ch-main');
var chSidebar = app.querySelector('.ch-sidebar');
chMain.addEventListener('transitionend', function () {
var layout = app.querySelector('.ch-layout');
if (layout && layout.classList.contains('ch-show-main')) {
chSidebar.style.pointerEvents = 'none';
} else {
chSidebar.style.pointerEvents = '';
}
});
// Event delegation for data-action buttons
app.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
@@ -304,6 +343,21 @@
hoverTimeout = setTimeout(hideNodeTooltip, 100);
}
});
// #86: Show tooltip on focus for keyboard users
msgEl.addEventListener('focusin', (e) => {
const el = e.target.closest('[data-node]');
if (el) {
clearTimeout(hoverTimeout);
const name = decodeURIComponent(atob(el.dataset.node));
showNodeTooltip(e, name);
}
});
msgEl.addEventListener('focusout', (e) => {
const el = e.target.closest('[data-node]');
if (el) {
hoverTimeout = setTimeout(hideNodeTooltip, 100);
}
});
wsHandler = debouncedOnWS(function (msgs) {
var dominated = msgs.some(function (m) {
@@ -366,7 +420,7 @@
const encClass = ch.encrypted ? ' ch-item-encrypted' : '';
const abbr = name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase();
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}" type="button" aria-label="${escapeHtml(name)}">
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}" type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
<div class="ch-badge" style="background:${color}" aria-hidden="true">${escapeHtml(abbr)}</div>
<div class="ch-item-body">
<div class="ch-item-top">
@@ -411,15 +465,17 @@
try {
const data = await api(`/channels/${selectedHash}/messages?limit=200`);
const newMsgs = data.messages || [];
// Compare last message timestamp instead of count — count stays same at limit
const lastOld = messages.length ? messages[messages.length - 1]?.timestamp : null;
const lastNew = newMsgs.length ? newMsgs[newMsgs.length - 1]?.timestamp : null;
if (newMsgs.length === messages.length && lastOld === lastNew) return;
// #92: Use message ID/hash for change detection instead of count + timestamp
var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; };
if (newMsgs.length === messages.length && _getLastId(newMsgs) === _getLastId(messages)) return;
var prevLen = messages.length;
messages = newMsgs;
renderMessages();
if (wasAtBottom) scrollToBottom();
else {
document.getElementById('chScrollBtn')?.classList.remove('hidden');
var liveEl = document.getElementById('chAriaLive');
if (liveEl) liveEl.textContent = Math.max(1, newMsgs.length - prevLen) + ' new messages';
}
} catch {}
}
+27 -16
View File
@@ -83,8 +83,10 @@
}
let directPacketId = null;
let initGeneration = 0;
async function init(app, routeParam) {
const gen = ++initGeneration;
// Detect route param type: "id/123" for direct packet, short hex for hash, long hex for node
if (routeParam) {
if (routeParam.startsWith('id/')) {
@@ -120,6 +122,7 @@
directPacketId = null;
try {
const data = await api(`/packets/${pktId}`);
if (gen !== initGeneration) return;
if (data.packet?.hash) {
filters.hash = data.packet.hash;
const hashInput = document.getElementById('fHash');
@@ -158,6 +161,14 @@
selectedId = null;
filtersBuilt = false;
delete filters.node;
expandedHashes = new Set();
hopNameCache = {};
totalCount = 0;
observers = [];
directPacketId = null;
groupByHash = true;
filters = {};
regionMap = {};
}
async function loadObservers() {
@@ -243,8 +254,8 @@
</div>
<table class="data-table" id="pktTable">
<thead><tr>
<th></th><th class="col-region">Region</th><th>Time</th><th>Hash</th><th class="col-size">Size</th>
<th>Type</th><th>Observer</th><th>Path</th><th class="col-rpt">Rpt</th><th>Details</th>
<th></th><th class="col-region">Region</th><th class="col-time">Time</th><th class="col-hash">Hash</th><th class="col-size">Size</th>
<th class="col-type">Type</th><th class="col-observer">Observer</th><th class="col-path">Path</th><th class="col-rpt">Rpt</th><th class="col-details">Details</th>
</tr></thead>
<tbody id="pktBody"></tbody>
</table>
@@ -412,14 +423,14 @@
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
<td>${timeAgo(p.latest)}</td>
<td class="mono">${truncate(p.hash || '—', 8)}</td>
<td class="col-time">${timeAgo(p.latest)}</td>
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
<td>${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
<td>${isSingle ? truncate(p.observer_name || p.observer_id || '—', 16) : truncate(p.observer_name || p.observer_id || '—', 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
<td><span class="path-hops">${groupPathStr}</span></td>
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
<td class="col-observer">${isSingle ? truncate(p.observer_name || p.observer_id || '—', 16) : truncate(p.observer_name || p.observer_id || '—', 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
<td class="col-rpt">${isSingle ? '' : p.count}</td>
<td>${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
</tr>`;
// Child rows (loaded async when expanded)
if (isExpanded && p._children) {
@@ -433,14 +444,14 @@
const childPathStr = renderPath(childPath);
html += `<tr class="group-child" data-id="${c.id}" data-action="select" data-value="${c.id}" tabindex="0" role="row">
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
<td>${timeAgo(c.timestamp)}</td>
<td class="mono">${truncate(c.hash || '', 8)}</td>
<td class="col-time">${timeAgo(c.timestamp)}</td>
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
<td class="col-size">${size}B</td>
<td><span class="badge badge-${typeClass}">${typeName}</span></td>
<td>${truncate(c.observer_name || c.observer_id || '—', 16)}</td>
<td><span class="path-hops">${childPathStr}</span></td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
<td class="col-observer">${truncate(c.observer_name || c.observer_id || '—', 16)}</td>
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
<td class="col-rpt"></td>
<td>${getDetailPreview((() => { try { return JSON.parse(c.decoded_json); } catch { return {}; } })())}</td>
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(c.decoded_json); } catch { return {}; } })())}</td>
</tr>`;
}
}
@@ -463,8 +474,8 @@
return `<tr data-id="${p.id}" data-action="select" data-value="${p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
<td>${timeAgo(p.timestamp)}</td>
<td class="mono">${truncate(p.hash || String(p.id), 8)}</td>
<td class="col-time">${timeAgo(p.timestamp)}</td>
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
<td class="col-size">${size}B</td>
<td><span class="badge badge-${typeClass}">${typeName}</span></td>
<td>${truncate(p.observer_name || p.observer_id || '—', 16)}</td>
+20 -5
View File
@@ -433,6 +433,14 @@ button.ch-item.selected { background: var(--selected-bg); }
.ch-item-preview { font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ch-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; }
/* Sidebar resize handle (#89) */
.ch-sidebar-resize {
position: absolute; top: 0; right: -3px; width: 6px; height: 100%;
cursor: col-resize; z-index: 10; background: transparent;
}
.ch-sidebar-resize:hover { background: var(--accent); opacity: 0.3; }
.ch-sidebar { position: relative; }
.ch-main-header {
padding: 14px 20px; font-size: 16px; font-weight: 700;
border-bottom: 1px solid var(--border); background: var(--card-bg);
@@ -488,7 +496,7 @@ button.ch-item.selected { background: var(--selected-bg); }
.ch-node-tooltip {
position: fixed; z-index: 1000; background: var(--card-bg); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 14px; box-shadow: 0 4px 16px rgba(0,0,0,.15);
min-width: 180px; max-width: 260px; pointer-events: none;
min-width: 180px; max-width: 260px;
}
.ch-tooltip-name { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
.ch-tooltip-role { font-size: 12px; color: var(--text-muted); margin-bottom: 2px; }
@@ -815,7 +823,6 @@ button.ch-item.selected { background: var(--selected-bg); }
z-index: 3; background: var(--content-bg);
}
.ch-layout.ch-show-main .ch-main { transform: translateX(0); }
.ch-layout.ch-show-main .ch-sidebar { pointer-events: none; }
.ch-back-btn { display: flex; }
.ch-main-header { display: flex; align-items: center; gap: 8px; }
@@ -977,7 +984,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
min-height: 36px;
font-size: 14px;
}
.ch-avatar.ch-tappable { min-width: 40px; min-height: 40px; width: 40px; height: 40px; }
.ch-avatar.ch-tappable { min-width: 44px; min-height: 44px; width: 44px; height: 44px; }
}
/* Full-screen node detail */
@@ -1044,7 +1051,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.tab-btn { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 13px; transition: all .15s; }
.tab-btn:hover { background: var(--hover-bg, rgba(0,0,0,.04)); }
.tab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 240px)); gap: 12px; margin-bottom: 16px; }
.stat-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center; }
.stat-value { font-size: 24px; font-weight: 700; color: var(--text); }
.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
@@ -1173,7 +1180,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.hop-prefix { color: #9ca3af; font-size: 0.8em; }
/* Subpath split layout */
.subpath-layout { display: flex; gap: 0; height: calc(100vh - 160px); position: relative; }
.subpath-layout { display: flex; gap: 0; flex: 1; min-height: 0; overflow: auto; position: relative; }
.subpath-list { flex: 1; overflow-y: auto; padding: 16px; min-width: 0; }
.subpath-detail { width: 420px; min-width: 360px; max-width: 50vw; border-left: 1px solid var(--border, #e5e7eb); overflow-y: auto; padding: 16px; transition: width 0.2s; }
.subpath-detail.collapsed { width: 0; min-width: 0; padding: 0; overflow: hidden; border: none; }
@@ -1198,6 +1205,14 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.subpath-detail { width: 100%; border-left: none; border-top: 1px solid var(--border, #e5e7eb); }
}
@media (max-width: 480px) {
.subpath-detail { min-width: 100%; width: 100%; max-width: 100%; }
.subpath-layout { flex-direction: column; }
}
/* Legend swatches */
.legend-swatch { display: inline-block; width: 12px; height: 12px; border: 1px solid var(--border); vertical-align: middle; }
/* Subpath jump nav */
.subpath-jump-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 0.9em; flex-wrap: wrap; }
.subpath-jump-nav span { color: #9ca3af; }