feat: add regional filters to all tabs

Fixes Kpa-clawbot/meshcore-analyzer#111
This commit is contained in:
you
2026-03-21 05:41:02 +00:00
parent 17f8d09f3e
commit b236b41568
9 changed files with 310 additions and 36 deletions
+14 -4
View File
@@ -65,6 +65,7 @@
<div class="analytics-header">
<h2>📊 Mesh Analytics</h2>
<p class="text-muted">Deep dive into your mesh network data</p>
<div id="analyticsRegionFilter" class="region-filter-container"></div>
<div class="analytics-tabs" id="analyticsTabs">
<button class="tab-btn active" data-tab="overview">Overview</button>
<button class="tab-btn" data-tab="rf">RF / Signal</button>
@@ -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');
+11 -1
View File
@@ -213,12 +213,15 @@
});
}
let regionChangeHandler = null;
function init(app, routeParam) {
app.innerHTML = `<div class="ch-layout">
<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 id="chRegionFilter" class="region-filter-container" style="padding:0 8px"></div>
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
<div class="ch-loading">Loading channels</div>
</div>
@@ -237,6 +240,9 @@
</div>
</div>`;
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) {
+8 -7
View File
@@ -22,7 +22,7 @@
<meta name="twitter:title" content="MeshCore Analyzer">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774042199">
<link rel="stylesheet" href="style.css?v=1774290000">
<link rel="stylesheet" href="home.css">
<link rel="stylesheet" href="live.css?v=1774058575">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
@@ -79,17 +79,18 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774080000"></script>
<script src="roles.js?v=1774290000"></script>
<script src="region-filter.js?v=1774290000"></script>
<script src="app.js?v=1774052279"></script>
<script src="home.js?v=1774042199"></script>
<script src="packets.js?v=1774059825"></script>
<script src="map.js?v=1774083841" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774050030" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774071292" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774048777" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774083840" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774290000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774290000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774290000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774290000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774260000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774018095" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774290000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774042199" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1773985649" onerror="console.error('Failed to load:', this.src)"></script>
+11 -1
View File
@@ -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 @@
<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 id="nodesRegionFilter" class="region-filter-container"></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>`;
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 || {};
+19 -7
View File
@@ -5,6 +5,7 @@
let observers = [];
let wsHandler = null;
let refreshTimer = null;
let regionChangeHandler = null;
function init(app) {
app.innerHTML = `
@@ -13,8 +14,11 @@
<h2>Observer Status</h2>
<button class="btn-icon" data-action="obs-refresh" title="Refresh" aria-label="Refresh observers">🔄</button>
</div>
<div id="obsRegionFilter" class="region-filter-container"></div>
<div id="obsContent"><div class="text-center text-muted" style="padding:40px">Loading</div></div>
</div>`;
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 = '<div class="text-center text-muted" style="padding:40px">No observers found.</div>';
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 = `
<div class="obs-summary">
<span class="obs-stat"><span class="health-dot health-green"></span> ${online} Online</span>
<span class="obs-stat"><span class="health-dot health-yellow"></span> ${stale} Stale</span>
<span class="obs-stat"><span class="health-dot health-red"></span> ${offline} Offline</span>
<span class="obs-stat">📡 ${observers.length} Total</span>
<span class="obs-stat">📡 ${filtered.length} Total</span>
</div>
<div class="obs-table-scroll"><table class="data-table obs-table" id="obsTable">
<caption class="sr-only">Observer status and statistics</caption>
@@ -103,7 +115,7 @@
<th>Status</th><th>Name</th><th>Region</th><th>Last Seen</th>
<th>Packets</th><th>Packets/Hour</th><th>Uptime</th>
</tr></thead>
<tbody>${observers.map(o => {
<tbody>${filtered.map(o => {
const h = healthStatus(o.last_seen);
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
return `<tr style="cursor:pointer" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
+141
View File
@@ -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: "&region=SJC,SFO" or "" */
function regionQueryString() {
var p = getRegionParam();
return p ? '&region=' + 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 = '<div class="region-filter-bar">';
html += '<button class="region-pill' + (allSelected ? ' region-pill-active' : '') + '" data-region="__all__">All</button>';
codes.forEach(function (code) {
var label = _regions[code] || code;
var active = allSelected || (_selected && _selected.has(code));
html += '<button class="region-pill' + (active ? ' region-pill-active' : '') + '" data-region="' + code + '">' + label + '</button>';
});
html += '</div>';
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
};
})();
+14
View File
@@ -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; }
+22
View File
@@ -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 @@
<div class="page-header">
<h2>🔍 Packet Trace</h2>
</div>
<div id="tracesRegionFilter" class="region-filter-container"></div>
<div class="trace-search">
<input type="text" id="traceHashInput" placeholder="Enter packet hash…" value="${urlHash}" aria-label="Packet hash to trace">
<button class="btn-primary" id="traceBtn">Trace</button>
@@ -23,6 +25,16 @@
<div id="traceResults"></div>
</div>`;
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 = '<div class="trace-empty">No observations found for this packet hash.</div>';
return;
+70 -16
View File
@@ -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);
});