diff --git a/public/analytics.js b/public/analytics.js index 65d95c18..c2b00aeb 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1140,20 +1140,18 @@ async function renderNodesTab(el) { el.innerHTML = '
Loading node analytics…
'; try { - const nodesResp = await api('/nodes?limit=200&sortBy=lastSeen'); + const [nodesResp, bulkHealth] = await Promise.all([ + api('/nodes?limit=200&sortBy=lastSeen'), + api('/nodes/bulk-health?limit=50') + ]); const nodes = nodesResp.nodes || nodesResp; const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]'); const myKeys = new Set(myNodes.map(n => n.pubkey)); - // Fetch health data for top nodes (limit to avoid hammering) - const topNodes = nodes.slice(0, 50); - const healthResults = await Promise.allSettled( - topNodes.map(n => api('/nodes/' + encodeURIComponent(n.public_key) + '/health').then(h => ({ ...n, health: h }))) - ); - const enriched = healthResults - .filter(r => r.status === 'fulfilled') - .map(r => r.value) - .filter(n => n.health); + // Map bulk health by pubkey + const healthMap = {}; + bulkHealth.forEach(h => { healthMap[h.public_key] = h; }); + const enriched = nodes.filter(n => healthMap[n.public_key]).map(n => ({ ...n, health: { stats: healthMap[n.public_key].stats, observers: healthMap[n.public_key].observers } })); // Compute rankings const byPackets = [...enriched].sort((a, b) => (b.health.stats.totalPackets || 0) - (a.health.stats.totalPackets || 0)); diff --git a/public/index.html b/public/index.html index f0f5f58a..620b50b8 100644 --- a/public/index.html +++ b/public/index.html @@ -83,9 +83,9 @@ - + - + diff --git a/server.js b/server.js index 58fbcf8b..249de366 100644 --- a/server.js +++ b/server.js @@ -1266,6 +1266,47 @@ app.get('/api/nodes/:pubkey/health', (req, res) => { res.json(health); }); +// Bulk health summary for analytics — single query approach +app.get('/api/nodes/bulk-health', (req, res) => { + const limit = Math.min(Number(req.query.limit) || 50, 200); + const nodes = db.db.prepare(`SELECT * FROM nodes ORDER BY last_seen DESC LIMIT ?`).all(limit); + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + const todayISO = todayStart.toISOString(); + + const results = nodes.map(node => { + const pk = node.public_key; + const keyPattern = `%${pk}%`; + const namePattern = node.name ? `%${node.name.replace(/[%_]/g, '')}%` : null; + const where = namePattern + ? `(decoded_json LIKE @k OR decoded_json LIKE @n)` + : `decoded_json LIKE @k`; + const p = namePattern ? { k: keyPattern, n: namePattern } : { k: keyPattern }; + + const observerRows = db.db.prepare(` + SELECT observer_id, observer_name, AVG(snr) as avgSnr, AVG(rssi) as avgRssi, COUNT(*) as packetCount + FROM packets WHERE ${where} AND observer_id IS NOT NULL GROUP BY observer_id ORDER BY packetCount DESC + `).all(p); + + const totalPackets = db.db.prepare(`SELECT COUNT(*) as c FROM packets WHERE ${where}`).get(p).c; + const packetsToday = db.db.prepare(`SELECT COUNT(*) as c FROM packets WHERE ${where} AND timestamp > @s`).get({ ...p, s: todayISO }).c; + const avgSnr = db.db.prepare(`SELECT AVG(snr) as v FROM packets WHERE ${where}`).get(p).v; + const lastHeard = db.db.prepare(`SELECT MAX(timestamp) as v FROM packets WHERE ${where}`).get(p).v; + + return { + public_key: pk, + name: node.name, + role: node.role, + lat: node.lat, + lon: node.lon, + stats: { totalPackets, packetsToday, avgSnr, lastHeard }, + observers: observerRows + }; + }); + + res.json(results); +}); + app.get('/api/nodes/:pubkey/analytics', (req, res) => { const days = Math.min(Math.max(Number(req.query.days) || 7, 1), 365); const data = db.getNodeAnalytics(req.params.pubkey, days);