mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 07:58:23 +00:00
feat: add regional filters to all tabs
Fixes Kpa-clawbot/meshcore-analyzer#111
This commit is contained in:
+14
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)}'">
|
||||
|
||||
@@ -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 = '<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
|
||||
};
|
||||
})();
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user