diff --git a/public/analytics.js b/public/analytics.js index 73aefbe6..6349f7fe 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -65,6 +65,7 @@

📊 Mesh Analytics

Deep dive into your mesh network data

+
@@ -92,6 +93,9 @@ renderTab(btn.dataset.tab); }); + RegionFilter.init(document.getElementById('analyticsRegionFilter')); + RegionFilter.onChange(function () { loadAnalytics(); }); + // Delegated click/keyboard handler for clickable table rows const analyticsContent = document.getElementById('analyticsContent'); if (analyticsContent) { @@ -106,13 +110,19 @@ analyticsContent.addEventListener('keydown', handler); } + loadAnalytics(); + } + + async function loadAnalytics() { try { _analyticsData = {}; + const rqs = RegionFilter.regionQueryString(); + const sep = rqs ? '?' + rqs.slice(1) : ''; const [hashData, rfData, topoData, chanData] = await Promise.all([ - api('/analytics/hash-sizes', { ttl: CLIENT_TTL.analyticsRF }), - api('/analytics/rf', { ttl: CLIENT_TTL.analyticsRF }), - api('/analytics/topology', { ttl: CLIENT_TTL.analyticsRF }), - api('/analytics/channels', { ttl: CLIENT_TTL.analyticsRF }), + api('/analytics/hash-sizes' + sep, { ttl: CLIENT_TTL.analyticsRF }), + api('/analytics/rf' + sep, { ttl: CLIENT_TTL.analyticsRF }), + api('/analytics/topology' + sep, { ttl: CLIENT_TTL.analyticsRF }), + api('/analytics/channels' + sep, { ttl: CLIENT_TTL.analyticsRF }), ]); _analyticsData = { hashData, rfData, topoData, chanData }; renderTab('overview'); diff --git a/public/channels.js b/public/channels.js index fef21dda..02bde549 100644 --- a/public/channels.js +++ b/public/channels.js @@ -213,12 +213,15 @@ }); } + let regionChangeHandler = null; + function init(app, routeParam) { app.innerHTML = `
đź’¬ Channels
+
Loading channels…
@@ -237,6 +240,9 @@
`; + RegionFilter.init(document.getElementById('chRegionFilter')); + regionChangeHandler = RegionFilter.onChange(function () { loadChannels(); }); + loadChannels().then(() => { if (routeParam) selectChannel(routeParam); }); @@ -382,6 +388,8 @@ function destroy() { if (wsHandler) offWS(wsHandler); wsHandler = null; + if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler); + regionChangeHandler = null; channels = []; messages = []; selectedHash = null; @@ -393,7 +401,9 @@ async function loadChannels(silent) { try { - const data = await api('/channels', { ttl: CLIENT_TTL.channels }); + const rp = RegionFilter.getRegionParam(); + const qs = rp ? '?region=' + encodeURIComponent(rp) : ''; + const data = await api('/channels' + qs, { ttl: CLIENT_TTL.channels }); channels = (data.channels || []).sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || '')); renderChannelList(); } catch (e) { diff --git a/public/index.html b/public/index.html index 8e8b0b07..8d5ae276 100644 --- a/public/index.html +++ b/public/index.html @@ -22,7 +22,7 @@ - + - + + - - - - + + + + - + diff --git a/public/nodes.js b/public/nodes.js index 1d0ebd31..a318ecfb 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -35,6 +35,8 @@ let directNode = null; // set when navigating directly to #/nodes/:pubkey + let regionChangeHandler = null; + function init(app, routeParam) { directNode = routeParam || null; @@ -66,12 +68,16 @@
+
Select a node to view details
`; + RegionFilter.init(document.getElementById('nodesRegionFilter')); + regionChangeHandler = RegionFilter.onChange(function () { loadNodes(); }); + document.getElementById('nodeSearch').addEventListener('input', debounce(e => { search = e.target.value; loadNodes(); @@ -256,10 +262,12 @@ } } - function destroy() { + function destroy() { if (wsHandler) offWS(wsHandler); wsHandler = null; if (detailMap) { detailMap.remove(); detailMap = null; } + if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler); + regionChangeHandler = null; nodes = []; selectedKey = null; } @@ -270,6 +278,8 @@ if (activeTab !== 'all') params.set('role', activeTab); if (search) params.set('search', search); if (lastHeard) params.set('lastHeard', lastHeard); + const rp = RegionFilter.getRegionParam(); + if (rp) params.set('region', rp); const data = await api('/nodes?' + params, { ttl: CLIENT_TTL.nodeList }); nodes = data.nodes || []; counts = data.counts || {}; diff --git a/public/observers.js b/public/observers.js index 8a4d854d..b3b1a534 100644 --- a/public/observers.js +++ b/public/observers.js @@ -5,6 +5,7 @@ let observers = []; let wsHandler = null; let refreshTimer = null; + let regionChangeHandler = null; function init(app) { app.innerHTML = ` @@ -13,8 +14,11 @@

Observer Status

+
Loading…
`; + RegionFilter.init(document.getElementById('obsRegionFilter')); + regionChangeHandler = RegionFilter.onChange(function () { render(); }); loadObservers(); // Event delegation for data-action buttons app.addEventListener('click', function (e) { @@ -33,6 +37,8 @@ wsHandler = null; if (refreshTimer) clearInterval(refreshTimer); refreshTimer = null; + if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler); + regionChangeHandler = null; observers = []; } @@ -78,24 +84,30 @@ const el = document.getElementById('obsContent'); if (!el) return; - if (observers.length === 0) { + // Apply region filter + const selectedRegions = RegionFilter.getSelected(); + const filtered = selectedRegions + ? observers.filter(o => o.iata && selectedRegions.includes(o.iata)) + : observers; + + if (filtered.length === 0) { el.innerHTML = '
No observers found.
'; return; } - const maxPktsHr = Math.max(1, ...observers.map(o => o.packetsLastHour || 0)); + const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0)); // Summary counts - const online = observers.filter(o => healthStatus(o.last_seen).cls === 'health-green').length; - const stale = observers.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length; - const offline = observers.filter(o => healthStatus(o.last_seen).cls === 'health-red').length; + const online = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-green').length; + const stale = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length; + const offline = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-red').length; el.innerHTML = `
● ${online} Online ▲ ${stale} Stale ✕ ${offline} Offline - 📡 ${observers.length} Total + 📡 ${filtered.length} Total
@@ -103,7 +115,7 @@ - ${observers.map(o => { + ${filtered.map(o => { const h = healthStatus(o.last_seen); const shape = h.cls === 'health-green' ? 'â—Ź' : h.cls === 'health-yellow' ? 'â–˛' : 'âś•'; return ` diff --git a/public/region-filter.js b/public/region-filter.js new file mode 100644 index 00000000..25725e7f --- /dev/null +++ b/public/region-filter.js @@ -0,0 +1,141 @@ +/* === MeshCore Analyzer — region-filter.js (shared region filter component) === */ +'use strict'; + +(function () { + var LS_KEY = 'meshcore-region-filter'; + var _regions = {}; // { code: label } + var _selected = null; // Set of selected region codes, null = all + var _listeners = []; + var _loaded = false; + + function loadFromStorage() { + try { + var stored = JSON.parse(localStorage.getItem(LS_KEY)); + if (Array.isArray(stored) && stored.length > 0) return new Set(stored); + } catch (e) { /* ignore */ } + return null; // null = all selected + } + + function saveToStorage() { + if (!_selected) { + localStorage.removeItem(LS_KEY); + } else { + localStorage.setItem(LS_KEY, JSON.stringify(Array.from(_selected))); + } + } + + _selected = loadFromStorage(); + + /** Fetch regions from server */ + async function fetchRegions() { + if (_loaded) return _regions; + try { + var data = await fetch('/api/config/regions').then(function (r) { return r.json(); }); + _regions = data || {}; + _loaded = true; + // If stored selection has codes no longer valid, clean up + if (_selected) { + var codes = Object.keys(_regions); + var cleaned = new Set(); + _selected.forEach(function (c) { if (codes.includes(c)) cleaned.add(c); }); + _selected = cleaned.size > 0 ? cleaned : null; + saveToStorage(); + } + } catch (e) { + _regions = {}; + } + return _regions; + } + + /** Get selected regions as array, or null if all */ + function getSelected() { + if (!_selected || _selected.size === 0) return null; + return Array.from(_selected); + } + + /** Get region query param string for API calls: "SJC,SFO" or empty */ + function getRegionParam() { + var sel = getSelected(); + return sel ? sel.join(',') : ''; + } + + /** Build query string fragment: "®ion=SJC,SFO" or "" */ + function regionQueryString() { + var p = getRegionParam(); + return p ? '®ion=' + encodeURIComponent(p) : ''; + } + + /** Render the filter bar into a container element */ + function render(container) { + var codes = Object.keys(_regions); + if (codes.length < 2) { + container.innerHTML = ''; + container.style.display = 'none'; + return; + } + container.style.display = ''; + var allSelected = !_selected; + var html = '
'; + html += ''; + codes.forEach(function (code) { + var label = _regions[code] || code; + var active = allSelected || (_selected && _selected.has(code)); + html += ''; + }); + html += '
'; + container.innerHTML = html; + + container.onclick = function (e) { + var btn = e.target.closest('[data-region]'); + if (!btn) return; + var region = btn.dataset.region; + if (region === '__all__') { + _selected = null; + } else { + if (!_selected) { + // Switch from "all" to just this one + _selected = new Set([region]); + } else if (_selected.has(region)) { + _selected.delete(region); + if (_selected.size === 0) _selected = null; // back to all + } else { + _selected.add(region); + } + // If all individually selected, switch to "all" mode + if (_selected && _selected.size === codes.length) _selected = null; + } + saveToStorage(); + render(container); + _listeners.forEach(function (fn) { fn(getSelected()); }); + }; + } + + /** Subscribe to selection changes. Callback receives selected array or null */ + function onChange(fn) { + _listeners.push(fn); + return fn; + } + + /** Unsubscribe */ + function offChange(fn) { + _listeners = _listeners.filter(function (f) { return f !== fn; }); + } + + /** Initialize filter in a container, fetch regions, render, return promise */ + async function initFilter(container) { + await fetchRegions(); + render(container); + } + + // Expose globally + window.RegionFilter = { + init: initFilter, + render: render, + getSelected: getSelected, + getRegionParam: getRegionParam, + regionQueryString: regionQueryString, + onChange: onChange, + offChange: offChange, + fetchRegions: fetchRegions + }; +})(); diff --git a/public/style.css b/public/style.css index 5caf249a..9b74f4ee 100644 --- a/public/style.css +++ b/public/style.css @@ -1447,3 +1447,17 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); } .perf-table .perf-slow td { color: #ef4444; } .perf-table .perf-warn { background: rgba(251, 191, 36, 0.06); } .perf-table .perf-warn td { color: #f59e0b; } + +/* ─── Region filter bar ─── */ +.region-filter-bar { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; } +.region-filter-container { margin-bottom: 4px; } +.region-pill { + display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 16px; + font-size: 12px; font-weight: 500; cursor: pointer; border: 1.5px solid var(--border); + background: transparent; color: var(--text-muted); transition: all 0.15s; +} +.region-pill:hover { border-color: var(--accent); color: var(--accent); } +.region-pill-active { + background: var(--accent); color: #fff; border-color: var(--accent); +} +.region-pill-active:hover { opacity: 0.85; } diff --git a/public/traces.js b/public/traces.js index c47d1297..38aa7c49 100644 --- a/public/traces.js +++ b/public/traces.js @@ -5,6 +5,7 @@ let currentHash = null; let traceData = []; let packetMeta = null; + let observerRegionMap = {}; // observer_id → iata function init(app) { // Check URL for pre-filled hash @@ -16,6 +17,7 @@

🔍 Packet Trace

+
@@ -23,6 +25,16 @@
`; + RegionFilter.init(document.getElementById('tracesRegionFilter')); + RegionFilter.onChange(function () { if (currentHash) doTrace(); }); + + // Load observer region map for filtering + api('/observers', { ttl: 60000 }).then(function (data) { + (data.observers || []).forEach(function (o) { + if (o.iata) observerRegionMap[o.id] = o.iata; + }); + }).catch(function () {}); + document.getElementById('traceBtn').addEventListener('click', doTrace); document.getElementById('traceHashInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') doTrace(); @@ -35,6 +47,7 @@ currentHash = null; traceData = []; packetMeta = null; + observerRegionMap = {}; } function obsLabel(t) { @@ -65,6 +78,15 @@ traceData = traceResp.traces || []; const packets = pktResp.packets || []; + // Apply region filter to trace data + const selectedRegions = RegionFilter.getSelected(); + if (selectedRegions) { + traceData = traceData.filter(function (t) { + var region = observerRegionMap[t.observer]; + return region && selectedRegions.includes(region); + }); + } + if (traceData.length === 0 && packets.length === 0) { results.innerHTML = '
No observations found for this packet hash.
'; return; diff --git a/server.js b/server.js index 2029aed0..5fd6531c 100644 --- a/server.js +++ b/server.js @@ -255,6 +255,19 @@ app.get('/api/config/regions', (req, res) => { res.json(regions); }); +// Helper: get set of observer IDs matching region filter (comma-separated IATA codes) +function getObserverIdsForRegions(regionParam) { + if (!regionParam) return null; // null = no filter + const codes = regionParam.split(',').map(s => s.trim()).filter(Boolean); + if (codes.length === 0) return null; + const ids = new Set(); + const observers = db.getObservers(); + for (const o of observers) { + if (o.iata && codes.includes(o.iata)) ids.add(o.id); + } + return ids; +} + app.get('/api/config/map', (req, res) => { const defaults = config.mapDefaults || {}; res.json({ @@ -1009,12 +1022,37 @@ app.get('/api/nodes', (req, res) => { if (ms) { where.push('last_seen > @since'); params.since = new Date(Date.now() - ms).toISOString(); } } + // Region filtering: if region param is set, only include nodes seen by observers in those regions + const regionObsIds = getObserverIdsForRegions(region); + let regionNodeKeys = null; + if (regionObsIds) { + regionNodeKeys = new Set(); + for (const pkt of pktStore.packets) { + if (regionObsIds.has(pkt.observer_id)) { + try { + const d = JSON.parse(pkt.decoded_json || '{}'); + const pk = d.pubKey || d.public_key; + if (pk) regionNodeKeys.add(pk); + if (d.sender_key) regionNodeKeys.add(d.sender_key); + } catch {} + } + } + } + const clause = where.length ? 'WHERE ' + where.join(' AND ') : ''; const sortMap = { name: 'name ASC', lastSeen: 'last_seen DESC', packetCount: 'advert_count DESC' }; const order = sortMap[sortBy] || 'last_seen DESC'; - const nodes = db.db.prepare(`SELECT * FROM nodes ${clause} ORDER BY ${order} LIMIT @limit OFFSET @offset`).all({ ...params, limit: Number(limit), offset: Number(offset) }); - const total = db.db.prepare(`SELECT COUNT(*) as count FROM nodes ${clause}`).get(params).count; + let nodes, total; + if (regionNodeKeys) { + const allNodes = db.db.prepare(`SELECT * FROM nodes ${clause} ORDER BY ${order}`).all(params); + const filtered = allNodes.filter(n => regionNodeKeys.has(n.public_key)); + total = filtered.length; + nodes = filtered.slice(Number(offset), Number(offset) + Number(limit)); + } else { + nodes = db.db.prepare(`SELECT * FROM nodes ${clause} ORDER BY ${order} LIMIT @limit OFFSET @offset`).all({ ...params, limit: Number(limit), offset: Number(offset) }); + total = db.db.prepare(`SELECT COUNT(*) as count FROM nodes ${clause}`).get(params).count; + } const counts = {}; for (const r of ['repeater', 'room', 'companion', 'sensor']) { @@ -1170,9 +1208,12 @@ app.get('/api/nodes/:pubkey', (req, res) => { // --- Analytics API --- // --- RF Analytics --- app.get('/api/analytics/rf', (req, res) => { - const _c = cache.get('analytics:rf'); if (_c) return res.json(_c); + const { region } = req.query; + const regionObsIds = getObserverIdsForRegions(region); + const _ck = 'analytics:rf' + (region ? ':' + region : ''); + const _c = cache.get(_ck); if (_c) return res.json(_c); const PTYPES = { 0:'REQ',1:'RESPONSE',2:'TXT_MSG',3:'ACK',4:'ADVERT',5:'GRP_TXT',7:'ANON_REQ',8:'PATH',9:'TRACE',11:'CONTROL' }; - const packets = pktStore.filter(p => p.snr != null); + const packets = pktStore.filter(p => p.snr != null && (!regionObsIds || regionObsIds.has(p.observer_id))); const snrVals = packets.map(p => p.snr).filter(v => v != null); const rssiVals = packets.map(p => p.rssi).filter(v => v != null); @@ -1262,14 +1303,17 @@ app.get('/api/analytics/rf', (req, res) => { avgPacketSize: packetSizes.length ? Math.round(packetSizes.reduce((a, b) => a + b, 0) / packetSizes.length) : 0, packetsPerHour, payloadTypes, snrByType: snrByTypeArr, signalOverTime, scatterData, timeSpanHours }; - cache.set('analytics:rf', _rfResult, TTL.analyticsRF); + cache.set(_ck, _rfResult, TTL.analyticsRF); res.json(_rfResult); }); // --- Topology Analytics --- app.get('/api/analytics/topology', (req, res) => { - const _c = cache.get('analytics:topology'); if (_c) return res.json(_c); - const packets = pktStore.filter(p => p.path_json && p.path_json !== '[]'); + const { region } = req.query; + const regionObsIds = getObserverIdsForRegions(region); + const _ck = 'analytics:topology' + (region ? ':' + region : ''); + const _c = cache.get(_ck); if (_c) return res.json(_c); + const packets = pktStore.filter(p => p.path_json && p.path_json !== '[]' && (!regionObsIds || regionObsIds.has(p.observer_id))); const allNodes = db.db.prepare('SELECT public_key, name, lat, lon FROM nodes WHERE name IS NOT NULL').all(); const resolveHop = (hop, contextPositions) => { const h = hop.toLowerCase(); @@ -1428,14 +1472,17 @@ app.get('/api/analytics/topology', (req, res) => { multiObsNodes, bestPathList }; - cache.set('analytics:topology', _topoResult, TTL.analyticsTopology); + cache.set(_ck, _topoResult, TTL.analyticsTopology); res.json(_topoResult); }); // --- Channel Analytics --- app.get('/api/analytics/channels', (req, res) => { - const _c = cache.get('analytics:channels'); if (_c) return res.json(_c); - const packets = pktStore.filter(p => p.payload_type === 5 && p.decoded_json); + const { region } = req.query; + const regionObsIds = getObserverIdsForRegions(region); + const _ck = 'analytics:channels' + (region ? ':' + region : ''); + const _c = cache.get(_ck); if (_c) return res.json(_c); + const packets = pktStore.filter(p => p.payload_type === 5 && p.decoded_json && (!regionObsIds || regionObsIds.has(p.observer_id))); const channels = {}; const senderCounts = {}; @@ -1493,14 +1540,17 @@ app.get('/api/analytics/channels', (req, res) => { channelTimeline, msgLengths }; - cache.set('analytics:channels', _chanResult, TTL.analyticsChannels); + cache.set(_ck, _chanResult, TTL.analyticsChannels); res.json(_chanResult); }); app.get('/api/analytics/hash-sizes', (req, res) => { - const _c = cache.get('analytics:hash-sizes'); if (_c) return res.json(_c); + const { region } = req.query; + const regionObsIds = getObserverIdsForRegions(region); + const _ck = 'analytics:hash-sizes' + (region ? ':' + region : ''); + const _c = cache.get(_ck); if (_c) return res.json(_c); // Get all packets with raw_hex and non-empty paths from memory store - const packets = pktStore.filter(p => p.raw_hex && p.path_json && p.path_json !== '[]'); + const packets = pktStore.filter(p => p.raw_hex && p.path_json && p.path_json !== '[]' && (!regionObsIds || regionObsIds.has(p.observer_id))); const distribution = { 1: 0, 2: 0, 3: 0 }; const byHour = {}; // hour bucket → { 1: n, 2: n, 3: n } @@ -1580,7 +1630,7 @@ app.get('/api/analytics/hash-sizes', (req, res) => { topHops, multiByteNodes }; - cache.set('analytics:hash-sizes', _hsResult, TTL.analyticsHashSizes); + cache.set(_ck, _hsResult, TTL.analyticsHashSizes); res.json(_hsResult); }); @@ -1736,12 +1786,16 @@ const channelHashNames = {}; } app.get('/api/channels', (req, res) => { - const _c = cache.get('channels'); if (_c) return res.json(_c); + const { region } = req.query; + const regionObsIds = getObserverIdsForRegions(region); + const _ck = 'channels' + (region ? ':' + region : ''); + const _c = cache.get(_ck); if (_c) return res.json(_c); // Single pass: only scan type-5 packets via filter (already in memory) const channelMap = {}; for (const pkt of pktStore.all()) { if (pkt.payload_type !== 5) continue; + if (regionObsIds && !regionObsIds.has(pkt.observer_id)) continue; let decoded; try { decoded = JSON.parse(pkt.decoded_json); } catch { continue; } @@ -1786,7 +1840,7 @@ app.get('/api/channels', (req, res) => { } const _chResult = { channels: Object.values(channelMap) }; - cache.set('channels', _chResult, TTL.channels); + cache.set(_ck, _chResult, TTL.channels); res.json(_chResult); });
Observer status and statistics
StatusNameRegionLast Seen PacketsPackets/HourUptime