`;
loadObservers();
+ // Event delegation for data-action buttons
+ app.addEventListener('click', function (e) {
+ var btn = e.target.closest('[data-action]');
+ if (btn && btn.dataset.action === 'obs-refresh') loadObservers();
+ });
// Auto-refresh every 30s
refreshTimer = setInterval(loadObservers, 30000);
- wsHandler = (msg) => {
- if (msg.type === 'packet') loadObservers();
- };
- onWS(wsHandler);
+ wsHandler = debouncedOnWS(function (msgs) {
+ if (msgs.some(function (m) { return m.type === 'packet'; })) loadObservers();
+ });
}
function destroy() {
@@ -111,7 +115,6 @@
makeColumnsResizable('#obsTable', 'meshcore-obs-col-widths');
}
- window._obsRefresh = loadObservers;
registerPage('observers', { init, destroy });
})();
diff --git a/public/packets.js b/public/packets.js
index 3ef47ebd..bb3f638b 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -8,6 +8,7 @@
let filters = {};
let wsHandler = null;
let observers = [];
+ let regionMap = {};
const TYPE_NAMES = { 0:'Request', 1:'Response', 2:'Direct Msg', 3:'ACK', 4:'Advert', 5:'Channel Msg', 7:'Anon Req', 8:'Path', 9:'Trace', 11:'Control' };
function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; }
let totalCount = 0;
@@ -105,6 +106,14 @@
await loadObservers();
loadPackets();
+ // Event delegation for data-action buttons
+ app.addEventListener('click', function (e) {
+ var btn = e.target.closest('[data-action]');
+ if (!btn) return;
+ if (btn.dataset.action === 'pkt-refresh') loadPackets();
+ else if (btn.dataset.action === 'pkt-byop') showBYOP();
+ });
+
// If linked directly to a packet by ID, load its detail and filter list
if (directPacketId) {
const pktId = Number(directPacketId);
@@ -135,12 +144,11 @@
}
} catch {}
}
- wsHandler = (msg) => {
- if (msg.type === 'packet') {
- loadPackets(); // refresh on new packet
+ wsHandler = debouncedOnWS(function (msgs) {
+ if (msgs.some(function (m) { return m.type === 'packet'; })) {
+ loadPackets();
}
- };
- onWS(wsHandler);
+ });
}
function destroy() {
@@ -218,8 +226,8 @@
Latest Packets (${totalCount})
-
-
+
+
@@ -244,7 +252,7 @@
// Populate filter dropdowns
const regionSel = document.getElementById('fRegion');
- for (const [code, name] of Object.entries(window._regions || {})) {
+ for (const [code, name] of Object.entries(regionMap || {})) {
regionSel.innerHTML += ``;
}
@@ -766,7 +774,7 @@
(async () => {
try {
// We'll use a simple approach - hardcode from config
- window._regions = {"SJC":"San Jose, US","SFO":"San Francisco, US","OAK":"Oakland, US","MRY":"Monterey, US","LAR":"Los Angeles, US"};
+ regionMap = {"SJC":"San Jose, US","SFO":"San Francisco, US","OAK":"Oakland, US","MRY":"Monterey, US","LAR":"Los Angeles, US"};
} catch {}
})();
@@ -800,8 +808,6 @@
if (data.packets?.[0]) selectPacket(data.packets[0].id);
} catch {}
}
- window._pktRefresh = loadPackets;
- window._pktBYOP = showBYOP;
registerPage('packets', { init, destroy });
})();
diff --git a/reviews/review-analytics-channels.md b/reviews/review-analytics-channels.md
new file mode 100644
index 00000000..b806d902
--- /dev/null
+++ b/reviews/review-analytics-channels.md
@@ -0,0 +1,135 @@
+# UI/UX Review: Analytics, Channels & Observers Pages
+
+Reviewer: subagent | Date: 2026-03-19
+
+---
+
+## Analytics Page
+
+### Accessibility
+
+1. **[Major]** Tab buttons lack `role="tablist"` / `role="tab"` / `aria-selected` — screen readers can't identify the tab pattern. (`analytics.js` ~L60-68, the `.analytics-tabs` div and `.tab-btn` buttons)
+
+2. **[Major]** All SVG charts (bar charts, scatter plots, histograms, sparklines) have zero text alternatives — no `role="img"`, no `aria-label`, no `` element. Screen readers get nothing. (`analytics.js` — `barChart()` L27, `sparkSvg()` L14, `renderScatter()` L142, `histogram()` L42)
+
+3. **[Major]** Hash matrix cells use color alone (green/yellow/red) to convey collision status. Color-blind users can't distinguish them. No pattern/icon/text differentiation. (`analytics.js` ~L339-350)
+
+4. **[Minor]** `clickable-row` elements use `onclick` inline handlers on `
` — not keyboard-focusable, no `tabindex`, no `role="link"` or `role="button"`. (`analytics.js` L293, L318, L328 — multiple tables)
+
+5. **[Minor]** Observer selector buttons in Topology tab reuse `.tab-btn` class but lack proper ARIA tab semantics. (`analytics.js` ~L220)
+
+6. **[Minor]** Scatter plot quality zone labels ("Excellent", "Good", "Weak") use semi-transparent fills that may have insufficient contrast against various backgrounds. (`analytics.js` ~L166-170)
+
+### Mobile Responsive
+
+7. **[Major]** `.analytics-row` goes `flex-direction: column` on mobile (good), but the hash matrix table (`renderHashMatrix`) generates a fixed-width 16×16 grid with `cellSize=36px` → minimum ~600px wide. The `overflow-x:auto` wrapper helps but the detail panel beside it won't fit. (`analytics.js` ~L331, `style.css` — no specific mobile override for hash matrix)
+
+8. **[Minor]** SVG charts use fixed `max-height` values (e.g., `max-height:300px`, `max-height:160px`) which may waste space or clip on very small screens. Width is `100%` though, which is correct. (`analytics.js` ~L143, L189, L207)
+
+9. **[Minor]** `.subpath-layout` uses `height: calc(100vh - 160px)` — this assumes a specific header height. If the analytics tabs wrap to 2 lines on mobile, content gets clipped. (`style.css` — `.subpath-layout`)
+
+10. **[Minor]** Route Patterns subpath detail panel has `min-width: 360px` — won't fit on phones <375px even in column layout. (`style.css` — `.subpath-detail`)
+
+### Desktop Space Efficiency
+
+11. **[Minor]** `.analytics-page` has `max-width: 1600px` — reasonable for most content but the hash matrix + detail panel side-by-side could use more width on ultrawide monitors. (`style.css` — `.analytics-page`)
+
+12. **[Minor]** Overview stat cards use `minmax(160px, 1fr)` grid — on very wide screens you get many small cards in one row which looks sparse. Could benefit from a `max-width` per card. (`style.css` — `.stats-grid`)
+
+### Bugs / Inconsistencies
+
+13. **[Critical]** `svgLine()` function (L7-12) is defined but **never called anywhere**. Dead code. (`analytics.js` L7)
+
+14. **[Major]** `window._analyticsData` is set as a global — potential for conflicts with other scripts, and the `destroy()` function only does `delete window._analyticsData` but doesn't clean up event listeners on `#analyticsTabs`. (`analytics.js` L87, L460)
+
+15. **[Major]** `renderCollisions()` and `renderHashMatrix()` both independently fetch `/nodes?limit=2000` — duplicate API call when viewing the "Hash Collisions" tab. (`analytics.js` ~L329, L380)
+
+16. **[Minor]** `renderSubpaths` uses `async function` but is called without `await` in `renderTab()` switch — the loading state and error handling work via the function's internal try/catch, but the `requestAnimationFrame` column resize in `renderTab` will fire before the async content renders. (`analytics.js` L96 calls renderSubpaths, L99-103 does column resize immediately)
+
+17. **[Minor]** The `renderTab` function applies `makeColumnsResizable` to `.analytics-table` elements, but `makeColumnsResizable` is called without checking if it exists (it's presumably defined in `app.js`). No guard. (`analytics.js` L100)
+
+18. **[Minor]** `timeAgo()` and `api()` are used but not imported/defined in this file — relies on global scope from `app.js`. Not a bug per se but fragile coupling. (`analytics.js` multiple locations)
+
+19. **[Minor]** Hash matrix legend uses inline styles for color swatches rather than CSS classes — inconsistent with the rest of the codebase which uses `.legend-dot` class. (`analytics.js` ~L365)
+
+---
+
+## Channels Page
+
+### Accessibility
+
+20. **[Major]** Channel list items are `