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:
Kpa-clawbot
2026-03-27 12:07:50 -07:00
co-authored by Copilot
parent 2f5404edc3
commit 1457795e3e
4 changed files with 109 additions and 20 deletions
+71
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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 })),
+7
View File
@@ -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);
});