From ac7bd13ae8bc0bccd6ecb7a688784e590ac80d0c Mon Sep 17 00:00:00 2001 From: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Date: Fri, 27 Mar 2026 01:56:38 -0700 Subject: [PATCH] fix: filter totalNodes to active nodes (7-day window), fixes #133 The /api/stats endpoint returned totalNodes from SELECT COUNT(*) FROM nodes, which counts every node ever seen. On long-running instances this climbs to 6800+ for a ~200-400 node mesh. Changes: - totalNodes now counts only nodes with last_seen within the last 7 days - Added totalNodesAllTime field for the full historical count - Role counts (repeaters, rooms, etc.) also filtered to 7-day window - Added countActiveNodes and countActiveNodesByRole prepared statements - Added 6 tests verifying active vs all-time node counting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- db.js | 6 +++++- server.js | 5 +++-- test-db.js | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/db.js b/db.js index 81dda78d..36358d2f 100644 --- a/db.js +++ b/db.js @@ -346,6 +346,8 @@ const stmts = { getObservers: db.prepare(`SELECT * FROM observers ORDER BY last_seen DESC`), countPackets: db.prepare(`SELECT COUNT(*) as count FROM observations`), countNodes: db.prepare(`SELECT COUNT(*) as count FROM nodes`), + countActiveNodes: db.prepare(`SELECT COUNT(*) as count FROM nodes WHERE last_seen > ?`), + countActiveNodesByRole: db.prepare(`SELECT COUNT(*) as count FROM nodes WHERE role = ? AND last_seen > ?`), countObservers: db.prepare(`SELECT COUNT(*) as count FROM observers`), countRecentPackets: schemaVersion >= 3 ? db.prepare(`SELECT COUNT(*) as count FROM observations WHERE timestamp > CAST(strftime('%s', ?) AS INTEGER)`) @@ -579,6 +581,7 @@ function getObservers() { function getStats() { const oneHourAgo = new Date(Date.now() - 3600000).toISOString(); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600000).toISOString(); // Try to get transmission count from normalized schema let totalTransmissions = null; try { @@ -588,7 +591,8 @@ function getStats() { totalPackets: totalTransmissions || stmts.countPackets.get().count, totalTransmissions, totalObservations: stmts.countPackets.get().count, - totalNodes: stmts.countNodes.get().count, + totalNodes: stmts.countActiveNodes.get(sevenDaysAgo).count, + totalNodesAllTime: stmts.countNodes.get().count, totalObservers: stmts.countObservers.get().count, packetsLastHour: stmts.countRecentPackets.get(oneHourAgo).count, }; diff --git a/server.js b/server.js index d12746b6..54335514 100644 --- a/server.js +++ b/server.js @@ -876,10 +876,11 @@ app.use(express.json()); app.get('/api/stats', (req, res) => { const stats = db.getStats(); - // Get role counts + // Get role counts (active nodes only — same 7-day window as totalNodes) + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600000).toISOString(); const counts = {}; for (const role of ['repeater', 'room', 'companion', 'sensor']) { - const r = db.db.prepare(`SELECT COUNT(*) as count FROM nodes WHERE role = ?`).get(role); + const r = db.db.prepare(`SELECT COUNT(*) as count FROM nodes WHERE role = ? AND last_seen > ?`).get(role, sevenDaysAgo); counts[role + 's'] = r.count; } res.json({ ...stats, counts }); diff --git a/test-db.js b/test-db.js index fca0a4fa..863102e1 100644 --- a/test-db.js +++ b/test-db.js @@ -213,6 +213,33 @@ console.log('\ngetStats:'); assert(stats.totalObservers >= 1, 'totalObservers'); assert(typeof stats.totalPackets === 'number', 'totalPackets is number'); assert(typeof stats.packetsLastHour === 'number', 'packetsLastHour is number'); + assert(typeof stats.totalNodesAllTime === 'number', 'totalNodesAllTime is number'); + assert(stats.totalNodesAllTime >= stats.totalNodes, 'totalNodesAllTime >= totalNodes'); +} + +// --- getStats active node filtering --- +console.log('\ngetStats active node filtering:'); +{ + // Insert a node with last_seen 30 days ago (should be excluded from totalNodes) + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 3600000).toISOString(); + db.upsertNode({ public_key: 'deadnode0000000000000000deadnode00', name: 'DeadNode', role: 'repeater', last_seen: thirtyDaysAgo, first_seen: thirtyDaysAgo }); + + // Insert a node with last_seen now (should be included) + db.upsertNode({ public_key: 'livenode0000000000000000livenode00', name: 'LiveNode', role: 'companion', last_seen: new Date().toISOString() }); + + const stats = db.getStats(); + assert(stats.totalNodesAllTime > stats.totalNodes, 'dead node excluded from totalNodes but included in totalNodesAllTime'); + + // Verify the dead node is in totalNodesAllTime + const allTime = stats.totalNodesAllTime; + assert(allTime >= 3, 'totalNodesAllTime includes dead + live nodes'); + + // Verify active count doesn't include the 30-day-old node + // The dead node's last_seen is 30 days ago, window is 7 days + const nodeInDb = db.getNode('deadnode0000000000000000deadnode00'); + assert(nodeInDb !== null, 'dead node exists in DB'); + const liveNode = db.getNode('livenode0000000000000000livenode00'); + assert(liveNode !== null, 'live node exists in DB'); } // --- getNodeHealth ---