mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-14 21:55:08 +00:00
fix: track repeater last_heard from relay path hops
Repeaters that actively relay packets showed stale 'last seen' times because last_seen only updates on adverts (every 12h) and last_heard only tracked sender/recipient appearances, not relay hops. - Add lastPathSeenMap: full pubkey → ISO timestamp for path hop sightings - updatePathSeenTimestamps() resolves hop prefixes via hopPrefixToKey cache - /api/nodes uses max(pktStore timestamp, path hop timestamp) for last_heard - 4 new tests: hop-only nodes, stale vs fresh, pktStore priority, cache invalidation
This commit is contained in:
@@ -521,12 +521,17 @@ function broadcast(msg) {
|
||||
// Auto-create stub nodes from path hops (≥2 bytes / 4 hex chars)
|
||||
// When an advert arrives later with a full pubkey matching the prefix, upsertNode will upgrade it
|
||||
const hopNodeCache = new Set(); // Avoid repeated DB lookups for known hops
|
||||
// Track when nodes were last seen as relay hops in packet paths (full pubkey → ISO timestamp)
|
||||
const lastPathSeenMap = new Map();
|
||||
|
||||
// Sequential hop disambiguation — delegates to server-helpers.js (single source of truth)
|
||||
function disambiguateHops(hops, allNodes) {
|
||||
return _disambiguateHops(hops, allNodes, MAX_HOP_DIST_SERVER);
|
||||
}
|
||||
|
||||
// Cache hop prefix → full pubkey for lastPathSeenMap resolution
|
||||
const hopPrefixToKey = new Map();
|
||||
|
||||
function autoLearnHopNodes(hops, now) {
|
||||
for (const hop of hops) {
|
||||
if (hop.length < 4) continue; // Skip 1-byte hops — too ambiguous
|
||||
@@ -535,11 +540,33 @@ function autoLearnHopNodes(hops, now) {
|
||||
const existing = db.db.prepare("SELECT public_key FROM nodes WHERE LOWER(public_key) LIKE ?").get(hopLower + '%');
|
||||
if (existing) {
|
||||
hopNodeCache.add(hop);
|
||||
hopPrefixToKey.set(hopLower, existing.public_key);
|
||||
continue;
|
||||
}
|
||||
// Create stub node — role is likely repeater (most hops are)
|
||||
db.upsertNode({ public_key: hopLower, name: null, role: 'repeater', lat: null, lon: null, last_seen: now });
|
||||
hopNodeCache.add(hop);
|
||||
hopPrefixToKey.set(hopLower, hopLower); // stub uses prefix as key
|
||||
}
|
||||
}
|
||||
|
||||
// Update lastPathSeenMap for all hops in a packet path (including 1-byte hops)
|
||||
function updatePathSeenTimestamps(hops, now) {
|
||||
for (const hop of hops) {
|
||||
const hopLower = hop.toLowerCase();
|
||||
// Try cached resolution first
|
||||
let fullKey = hopPrefixToKey.get(hopLower);
|
||||
if (!fullKey) {
|
||||
// For 1-byte hops or uncached: try DB prefix match (single query)
|
||||
const existing = db.db.prepare("SELECT public_key FROM nodes WHERE LOWER(public_key) LIKE ?").get(hopLower + '%');
|
||||
if (existing) {
|
||||
fullKey = existing.public_key;
|
||||
hopPrefixToKey.set(hopLower, fullKey);
|
||||
}
|
||||
}
|
||||
if (fullKey) {
|
||||
lastPathSeenMap.set(fullKey, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +675,8 @@ for (const source of mqttSources) {
|
||||
if (decoded.path.hops.length > 0) {
|
||||
// Auto-create stub nodes from 2+ byte path hops
|
||||
autoLearnHopNodes(decoded.path.hops, now);
|
||||
// Track when each hop node was last seen relaying
|
||||
updatePathSeenTimestamps(decoded.path.hops, now);
|
||||
}
|
||||
|
||||
if (decoded.header.payloadTypeName === 'ADVERT' && decoded.payload.pubKey) {
|
||||
@@ -981,7 +1010,9 @@ app.post('/api/packets', requireApiKey, (req, res) => {
|
||||
try { db.insertTransmission(apiPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
|
||||
|
||||
if (decoded.path.hops.length > 0) {
|
||||
autoLearnHopNodes(decoded.path.hops, new Date().toISOString());
|
||||
const _now = new Date().toISOString();
|
||||
autoLearnHopNodes(decoded.path.hops, _now);
|
||||
updatePathSeenTimestamps(decoded.path.hops, _now);
|
||||
}
|
||||
|
||||
if (decoded.header.payloadTypeName === 'ADVERT' && decoded.payload.pubKey) {
|
||||
@@ -1075,6 +1106,11 @@ app.get('/api/nodes', (req, res) => {
|
||||
}
|
||||
if (latest) node.last_heard = latest;
|
||||
}
|
||||
// Also check if this node was seen as a relay hop in any packet path
|
||||
const pathSeen = lastPathSeenMap.get(node.public_key);
|
||||
if (pathSeen && (!node.last_heard || pathSeen > node.last_heard)) {
|
||||
node.last_heard = pathSeen;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ nodes, total, counts });
|
||||
@@ -2966,4 +3002,4 @@ function shutdown(signal) {
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
module.exports = { app, server, wss, pktStore, db, cache };
|
||||
module.exports = { app, server, wss, pktStore, db, cache, lastPathSeenMap };
|
||||
|
||||
@@ -7,6 +7,7 @@ process.env.SEED_DB = 'true'; // Seed test data
|
||||
|
||||
const request = require('supertest');
|
||||
const { app, server, wss, pktStore, db, cache } = require('./server');
|
||||
const lastPathSeenMap = require('./server').lastPathSeenMap;
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
|
||||
@@ -1038,6 +1039,71 @@ seedTestData();
|
||||
assert(h.isHashSizeFlipFlop(null, null) === false);
|
||||
});
|
||||
|
||||
// ── lastPathSeenMap: repeater hop tracking ──
|
||||
await t('node appearing only as path hop gets last_heard', async () => {
|
||||
// Create a node that has NO packets in pktStore (only exists in DB)
|
||||
const hopPubkey = 'ffaa' + '0'.repeat(60);
|
||||
db.upsertNode({ public_key: hopPubkey, name: 'HopOnlyRepeater', role: 'repeater', lat: 0, lon: 0, last_seen: '2020-01-01T00:00:00.000Z' });
|
||||
|
||||
// Simulate it being seen as a path hop
|
||||
const recentTime = new Date().toISOString();
|
||||
lastPathSeenMap.set(hopPubkey, recentTime);
|
||||
|
||||
const res = await request(app).get('/api/nodes?search=HopOnlyRepeater');
|
||||
assert(res.status === 200);
|
||||
assert(res.body.nodes.length >= 1, 'should find the hop-only node');
|
||||
const node = res.body.nodes.find(n => n.public_key === hopPubkey);
|
||||
assert(node, 'node should exist in results');
|
||||
assert(node.last_heard === recentTime, 'last_heard should come from lastPathSeenMap');
|
||||
|
||||
// Cleanup
|
||||
lastPathSeenMap.delete(hopPubkey);
|
||||
});
|
||||
|
||||
await t('last_heard from path hop preferred over stale last_seen', async () => {
|
||||
const hopPubkey = 'ffbb' + '0'.repeat(60);
|
||||
const staleTime = '2020-01-01T00:00:00.000Z';
|
||||
const freshTime = new Date().toISOString();
|
||||
db.upsertNode({ public_key: hopPubkey, name: 'StaleRepeater', role: 'repeater', lat: 0, lon: 0, last_seen: staleTime });
|
||||
|
||||
// Path hop seen recently
|
||||
lastPathSeenMap.set(hopPubkey, freshTime);
|
||||
|
||||
const res = await request(app).get('/api/nodes?search=StaleRepeater');
|
||||
assert(res.status === 200);
|
||||
const node = res.body.nodes.find(n => n.public_key === hopPubkey);
|
||||
assert(node, 'node should exist');
|
||||
assert(node.last_heard === freshTime, 'last_heard should be fresh path time, not stale DB time');
|
||||
assert(node.last_seen === staleTime, 'last_seen (DB) should still be stale');
|
||||
|
||||
lastPathSeenMap.delete(hopPubkey);
|
||||
});
|
||||
|
||||
await t('last_heard from pktStore preferred over older path hop', async () => {
|
||||
const hopPubkey = 'aabb' + '0'.repeat(60); // TestRepeater1 — has packets in pktStore
|
||||
const oldPathTime = '2019-01-01T00:00:00.000Z';
|
||||
lastPathSeenMap.set(hopPubkey, oldPathTime);
|
||||
|
||||
const res = await request(app).get('/api/nodes?search=TestRepeater1');
|
||||
assert(res.status === 200);
|
||||
const node = res.body.nodes.find(n => n.public_key === hopPubkey);
|
||||
assert(node, 'node should exist');
|
||||
// pktStore should have a more recent timestamp than our old path time
|
||||
assert(node.last_heard > oldPathTime, 'pktStore timestamp should win over older path hop time');
|
||||
|
||||
lastPathSeenMap.delete(hopPubkey);
|
||||
});
|
||||
|
||||
await t('bulk-health cache invalidated after advert', () => {
|
||||
// Set a fake bulk-health cache entry
|
||||
cache.set('bulk-health:50:r=', { fake: true }, 60000);
|
||||
assert(cache.get('bulk-health:50:r='), 'cache should have bulk-health entry');
|
||||
|
||||
// Simulate what happens on advert: cache.invalidate('bulk-health')
|
||||
cache.invalidate('bulk-health');
|
||||
assert(!cache.get('bulk-health:50:r='), 'bulk-health cache should be invalidated after advert');
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n═══ Server Route Tests: ${passed} passed, ${failed} failed ═══`);
|
||||
if (failed > 0) process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user