mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 09:18:20 +00:00
fix: analytics channels + uniqueNodes mismatch
fixes #154: Go analytics channels showed single 'ch?' because channelHash is a JSON number (from decoder.js) but the Go struct declared it as string. json.Unmarshal failed on every packet. Changed to interface{} with proper type conversion. Also fixed chKey to use hash (not name) for grouping, matching Node.js. fixes #155: uniqueNodes in topology analytics used hop resolution count (phantom hops inflated it). Both Node.js and Go now use db.getStats().totalNodes (7-day active window), matching /api/stats. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -2066,6 +2066,19 @@ func TestStoreGetAnalyticsTopology(t *testing.T) {
|
||||
t.Error("expected non-nil result")
|
||||
}
|
||||
|
||||
// #155: uniqueNodes must match DB 7-day active count, not hop resolution
|
||||
stats, err := db.GetStats()
|
||||
if err != nil {
|
||||
t.Fatalf("GetStats failed: %v", err)
|
||||
}
|
||||
un, ok := result["uniqueNodes"].(int)
|
||||
if !ok {
|
||||
t.Fatalf("uniqueNodes is not int: %T", result["uniqueNodes"])
|
||||
}
|
||||
if un != stats.TotalNodes {
|
||||
t.Errorf("uniqueNodes=%d should match stats totalNodes=%d", un, stats.TotalNodes)
|
||||
}
|
||||
|
||||
t.Run("with region", func(t *testing.T) {
|
||||
r := store.GetAnalyticsTopology("SJC")
|
||||
_ = r
|
||||
@@ -2095,6 +2108,64 @@ func TestStoreGetAnalyticsChannels(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// Regression test for #154: channelHash is a number in decoded JSON from decoder.js,
|
||||
// not a string. The Go struct must handle both types correctly.
|
||||
func TestStoreGetAnalyticsChannelsNumericHash(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
recent := time.Now().Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
recentEpoch := time.Now().Add(-1 * time.Hour).Unix()
|
||||
|
||||
// Insert GRP_TXT packets with numeric channelHash (matches decoder.js output)
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('DD01', 'grp_num_hash_1', ?, 1, 5, '{"type":"GRP_TXT","channelHash":97,"channelHashHex":"61","decryptionStatus":"no_key"}')`, recent)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (3, 1, 10.0, -90, '[]', ?)`, recentEpoch)
|
||||
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('DD02', 'grp_num_hash_2', ?, 1, 5, '{"type":"GRP_TXT","channelHash":42,"channelHashHex":"2A","decryptionStatus":"no_key"}')`, recent)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (4, 1, 10.0, -90, '[]', ?)`, recentEpoch)
|
||||
|
||||
// Also a decrypted CHAN with numeric channelHash
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('DD03', 'chan_num_hash_3', ?, 1, 5, '{"type":"CHAN","channel":"general","channelHash":97,"channelHashHex":"61","text":"hello","sender":"Alice"}')`, recent)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (5, 1, 12.0, -88, '[]', ?)`, recentEpoch)
|
||||
|
||||
store := NewPacketStore(db)
|
||||
store.Load()
|
||||
result := store.GetAnalyticsChannels("")
|
||||
|
||||
channels := result["channels"].([]map[string]interface{})
|
||||
if len(channels) < 2 {
|
||||
t.Errorf("expected at least 2 channels (hash 97 + hash 42), got %d", len(channels))
|
||||
}
|
||||
|
||||
// Verify no channel has hash "?" (would mean parsing failed)
|
||||
for _, ch := range channels {
|
||||
if ch["hash"] == "?" {
|
||||
t.Errorf("channel has hash '?' — numeric channelHash was not parsed: %v", ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the decrypted CHAN channel has the correct name
|
||||
foundGeneral := false
|
||||
for _, ch := range channels {
|
||||
if ch["name"] == "general" {
|
||||
foundGeneral = true
|
||||
if ch["hash"] != "97" {
|
||||
t.Errorf("expected hash '97' for general channel, got %v", ch["hash"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundGeneral {
|
||||
t.Error("expected to find channel named 'general'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreGetAnalyticsDistance(t *testing.T) {
|
||||
db := setupRichTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
+30
-19
@@ -1340,12 +1340,27 @@ func (s *PacketStore) GetAnalyticsChannels(region string) map[string]interface{}
|
||||
}
|
||||
|
||||
type decodedGrp struct {
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel"`
|
||||
ChannelHash string `json:"channelHash"`
|
||||
ChannelHash2 string `json:"channel_hash"`
|
||||
Text string `json:"text"`
|
||||
Sender string `json:"sender"`
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel"`
|
||||
ChannelHash interface{} `json:"channelHash"`
|
||||
ChannelHash2 string `json:"channel_hash"`
|
||||
Text string `json:"text"`
|
||||
Sender string `json:"sender"`
|
||||
}
|
||||
|
||||
// Convert channelHash (number or string in JSON) to string
|
||||
chHashStr := func(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case float64:
|
||||
return strconv.FormatFloat(val, 'f', -1, 64)
|
||||
default:
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
}
|
||||
|
||||
type chanInfo struct {
|
||||
@@ -1382,7 +1397,7 @@ func (s *PacketStore) GetAnalyticsChannels(region string) map[string]interface{}
|
||||
continue
|
||||
}
|
||||
|
||||
hash := decoded.ChannelHash
|
||||
hash := chHashStr(decoded.ChannelHash)
|
||||
if hash == "" {
|
||||
hash = decoded.ChannelHash2
|
||||
}
|
||||
@@ -1391,14 +1406,11 @@ func (s *PacketStore) GetAnalyticsChannels(region string) map[string]interface{}
|
||||
}
|
||||
name := decoded.Channel
|
||||
if name == "" {
|
||||
if decoded.Type == "CHAN" {
|
||||
name = "ch" + hash
|
||||
} else {
|
||||
name = "ch" + hash
|
||||
}
|
||||
name = "ch" + hash
|
||||
}
|
||||
encrypted := decoded.Text == "" && decoded.Sender == ""
|
||||
chKey := name
|
||||
// Use hash as key for grouping (matches Node.js String(hash))
|
||||
chKey := hash
|
||||
if decoded.Type == "CHAN" && decoded.Channel != "" {
|
||||
chKey = hash + "_" + decoded.Channel
|
||||
}
|
||||
@@ -2427,14 +2439,13 @@ func (s *PacketStore) GetAnalyticsTopology(region string) map[string]interface{}
|
||||
bestPathList = bestPathList[:50]
|
||||
}
|
||||
|
||||
// Count only hops that resolve to real nodes (not unresolved 1-byte prefixes)
|
||||
resolvedSet := map[string]bool{}
|
||||
for hop := range hopFreq {
|
||||
if r := resolveHop(hop); r != nil {
|
||||
resolvedSet[r.PublicKey] = true
|
||||
// Use DB 7-day active node count (matches /api/stats totalNodes)
|
||||
uniqueNodes := 0
|
||||
if s.db != nil {
|
||||
if stats, err := s.db.GetStats(); err == nil {
|
||||
uniqueNodes = stats.TotalNodes
|
||||
}
|
||||
}
|
||||
uniqueNodes := len(resolvedSet)
|
||||
|
||||
return map[string]interface{}{
|
||||
"uniqueNodes": uniqueNodes,
|
||||
|
||||
@@ -1605,7 +1605,7 @@ app.get('/api/analytics/topology', (req, res) => {
|
||||
.slice(0, 50);
|
||||
|
||||
const _topoResult = {
|
||||
uniqueNodes: new Set(Object.keys(hopFreq)).size,
|
||||
uniqueNodes: db.getStats().totalNodes,
|
||||
avgHops, medianHops, maxHops,
|
||||
hopDistribution, topRepeaters, topPairs, hopsVsSnr,
|
||||
observers: observers.map(o => ({ id: o.observer_id, name: o.observer_name || o.observer_id })),
|
||||
|
||||
@@ -514,6 +514,13 @@ seedTestData();
|
||||
assert(typeof r.body === 'object', 'should return topology');
|
||||
});
|
||||
|
||||
await t('GET /api/analytics/topology uniqueNodes matches stats totalNodes (#155)', async () => {
|
||||
const topo = await request(app).get('/api/analytics/topology').expect(200);
|
||||
const stats = await request(app).get('/api/stats').expect(200);
|
||||
assert(topo.body.uniqueNodes === stats.body.totalNodes,
|
||||
`uniqueNodes (${topo.body.uniqueNodes}) should match stats totalNodes (${stats.body.totalNodes})`);
|
||||
});
|
||||
|
||||
await t('GET /api/analytics/topology with region', async () => {
|
||||
await request(app).get('/api/analytics/topology?region=SFO').expect(200);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user