mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 23:42:16 +00:00
317b59ab10
## Summary - Adds configurable GPS polygon areas to `config.json`; nodes are attributed to an area if their last-known position falls inside the polygon - New `Area: …` dropdown filter (matching the existing region filter style) appears on all analytics, nodes, packets, map, and live screens when areas are configured - Backend resolves area membership with a 30s TTL cache; area filter bypasses the 500-node cap on `/api/bulk-health` so all area nodes are always returned - Includes a polygon builder tool (`/area-map.html`) for drawing and exporting area boundaries ## Changes **Backend** - `AreaEntry` type + `Areas` config field - `GetNodePubkeysInArea` DB query + `resolveAreaNodes` (30s TTL, `areaNodeMu` RWMutex) - `PacketQuery.Area` + `filterPackets` polygon check - `?area=` param propagated through all analytics, topology, clock-health, and bulk-health routes - `/api/config/areas` endpoint **Frontend** - `area-filter.js`: single-select dropdown, persists to localStorage, cleans up stale keys on load - Wired into analytics, nodes, packets, channels, map, and live pages - Live map clears node markers on area change **Docs & tools** - `docs/user-guide/area-filter.md` — configuration and usage guide - `docs/api-spec.md` — updated with new endpoint and `?area=` param table - `tools/area-map.html` — polygon builder for defining area boundaries - Demo areas added to `config.example.json` ## Test plan - [x] No areas configured → filter dropdown does not appear on any page - [x] Areas configured → dropdown appears, "All" selected by default - [x] Selecting an area filters nodes/packets/topology/map correctly - [x] Selecting "All" restores unfiltered view - [x] Selection persists across page reloads (localStorage) - [x] Stale localStorage key (area removed from config) is cleared on load - [x] `/api/bulk-health?area=X` returns all nodes in area (no 500-node cap) - [x] `/api/config/areas` returns correct list 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com> Co-authored-by: openclaw-bot <bot@openclaw.local>
339 lines
12 KiB
Go
339 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
|
||
_ "modernc.org/sqlite"
|
||
)
|
||
|
||
// TestTopologyDedup_RepeatersMergeByPubkey verifies that topRepeaters
|
||
// merges entries whose hop prefixes resolve unambiguously to the same node.
|
||
func TestTopologyDedup_RepeatersMergeByPubkey(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dbPath := filepath.Join(dir, "test.db")
|
||
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer conn.Close()
|
||
|
||
exec := func(s string) {
|
||
if _, err := conn.Exec(s); err != nil {
|
||
t.Fatalf("SQL exec failed: %v\nSQL: %s", err, s)
|
||
}
|
||
}
|
||
exec(`CREATE TABLE transmissions (
|
||
id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT,
|
||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT
|
||
)`)
|
||
exec(`CREATE TABLE observations (
|
||
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
|
||
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||
)`)
|
||
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||
exec(`CREATE TABLE nodes (
|
||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
|
||
last_seen TEXT, frequency REAL
|
||
)`)
|
||
exec(`CREATE TABLE schema_version (version INTEGER)`)
|
||
exec(`INSERT INTO schema_version (version) VALUES (1)`)
|
||
exec(`CREATE INDEX idx_tx_first_seen ON transmissions(first_seen)`)
|
||
|
||
// Insert two repeater nodes with distinct pubkeys.
|
||
// AQUA: pubkey starts with 0735bc...
|
||
// BETA: pubkey starts with 99aabb...
|
||
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('0735bc6dda4d1122aabbccdd', 'AQUA', 'Repeater')`)
|
||
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('99aabb001122334455667788', 'BETA', 'Repeater')`)
|
||
|
||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||
|
||
// Create packets:
|
||
// - 10 packets with path ["07", "99aa"] (short prefix for AQUA, medium for BETA)
|
||
// - 5 packets with path ["0735bc", "99"] (medium prefix for AQUA, short for BETA)
|
||
// - 3 packets with path ["0735bc6dda4d", "99aabb"] (long prefix for both)
|
||
txID := 1
|
||
obsID := 1
|
||
insertTx := func(path string, count int) {
|
||
for i := 0; i < count; i++ {
|
||
ts := base.Add(time.Duration(txID) * time.Minute).Format(time.RFC3339)
|
||
hash := fmt.Sprintf("h%04d", txID)
|
||
conn.Exec("INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?, ?, ?, ?, 0, 4, 1, ?)",
|
||
txID, "aabb", hash, ts, fmt.Sprintf(`{"pubKey":"pk%04d"}`, txID))
|
||
conn.Exec("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
obsID, txID, "obs1", "Obs1", "RX", -10.0, -80.0, 5, path, ts)
|
||
txID++
|
||
obsID++
|
||
}
|
||
}
|
||
|
||
insertTx(`["07","99aa"]`, 10)
|
||
insertTx(`["0735bc","99"]`, 5)
|
||
insertTx(`["0735bc6d","99aabb"]`, 3)
|
||
|
||
// Total: AQUA appears as "07" (10×), "0735bc" (5×), "0735bc6d" (3×) = 18 total
|
||
// Total: BETA appears as "99aa" (10×), "99" (5×), "99aabb" (3×) = 18 total
|
||
// After dedup, each should appear ONCE with count=18.
|
||
|
||
db, err := OpenDB(dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer db.conn.Close()
|
||
|
||
store := NewPacketStore(db, &PacketStoreConfig{MaxMemoryMB: 100})
|
||
if err := store.Load(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
result := store.computeAnalyticsTopology("", "", TimeWindow{})
|
||
topRepeaters := result["topRepeaters"].([]map[string]interface{})
|
||
|
||
// Build a map of pubkey → total count from topRepeaters
|
||
pubkeyCounts := map[string]int{}
|
||
for _, entry := range topRepeaters {
|
||
pk, _ := entry["pubkey"].(string)
|
||
if pk == "" {
|
||
continue
|
||
}
|
||
pubkeyCounts[pk] += entry["count"].(int)
|
||
}
|
||
|
||
// Each pubkey should appear exactly once in topRepeaters
|
||
aquaEntries := 0
|
||
betaEntries := 0
|
||
for _, entry := range topRepeaters {
|
||
pk, _ := entry["pubkey"].(string)
|
||
if pk == "0735bc6dda4d1122aabbccdd" {
|
||
aquaEntries++
|
||
}
|
||
if pk == "99aabb001122334455667788" {
|
||
betaEntries++
|
||
}
|
||
}
|
||
|
||
if aquaEntries != 1 {
|
||
t.Errorf("AQUA should appear exactly once in topRepeaters after dedup, got %d entries", aquaEntries)
|
||
for _, e := range topRepeaters {
|
||
t.Logf(" entry: hop=%v name=%v pubkey=%v count=%v", e["hop"], e["name"], e["pubkey"], e["count"])
|
||
}
|
||
}
|
||
if betaEntries != 1 {
|
||
t.Errorf("BETA should appear exactly once in topRepeaters after dedup, got %d entries", betaEntries)
|
||
}
|
||
|
||
// Check that the merged count is correct (18 each)
|
||
if c := pubkeyCounts["0735bc6dda4d1122aabbccdd"]; c != 18 {
|
||
t.Errorf("AQUA total count should be 18, got %d", c)
|
||
}
|
||
if c := pubkeyCounts["99aabb001122334455667788"]; c != 18 {
|
||
t.Errorf("BETA total count should be 18, got %d", c)
|
||
}
|
||
}
|
||
|
||
// TestTopologyDedup_AmbiguousPrefixNotMerged verifies that ambiguous short
|
||
// prefixes (matching multiple nodes) are NOT merged — they stay separate.
|
||
func TestTopologyDedup_AmbiguousPrefixNotMerged(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dbPath := filepath.Join(dir, "test.db")
|
||
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer conn.Close()
|
||
|
||
exec := func(s string) {
|
||
if _, err := conn.Exec(s); err != nil {
|
||
t.Fatalf("SQL exec failed: %v\nSQL: %s", err, s)
|
||
}
|
||
}
|
||
exec(`CREATE TABLE transmissions (
|
||
id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT,
|
||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT
|
||
)`)
|
||
exec(`CREATE TABLE observations (
|
||
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
|
||
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||
)`)
|
||
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||
exec(`CREATE TABLE nodes (
|
||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
|
||
last_seen TEXT, frequency REAL
|
||
)`)
|
||
exec(`CREATE TABLE schema_version (version INTEGER)`)
|
||
exec(`INSERT INTO schema_version (version) VALUES (1)`)
|
||
exec(`CREATE INDEX idx_tx_first_seen ON transmissions(first_seen)`)
|
||
|
||
// Two nodes whose pubkeys share the prefix "ab" — collision!
|
||
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('ab11223344556677aabbccdd', 'NODE_A', 'Repeater')`)
|
||
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('ab99887766554433aabbccdd', 'NODE_B', 'Repeater')`)
|
||
|
||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||
txID := 1
|
||
obsID := 1
|
||
|
||
// 10 packets with hop "ab" — ambiguous (matches both NODE_A and NODE_B)
|
||
for i := 0; i < 10; i++ {
|
||
ts := base.Add(time.Duration(txID) * time.Minute).Format(time.RFC3339)
|
||
hash := fmt.Sprintf("h%04d", txID)
|
||
conn.Exec("INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?, ?, ?, ?, 0, 4, 1, ?)",
|
||
txID, "aabb", hash, ts, fmt.Sprintf(`{"pubKey":"pk%04d"}`, txID))
|
||
conn.Exec("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
obsID, txID, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `["ab"]`, ts)
|
||
txID++
|
||
obsID++
|
||
}
|
||
// 5 packets with hop "ab1122" — unambiguous (only NODE_A)
|
||
for i := 0; i < 5; i++ {
|
||
ts := base.Add(time.Duration(txID) * time.Minute).Format(time.RFC3339)
|
||
hash := fmt.Sprintf("h%04d", txID)
|
||
conn.Exec("INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?, ?, ?, ?, 0, 4, 1, ?)",
|
||
txID, "aabb", hash, ts, fmt.Sprintf(`{"pubKey":"pk%04d"}`, txID))
|
||
conn.Exec("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
obsID, txID, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `["ab1122"]`, ts)
|
||
txID++
|
||
obsID++
|
||
}
|
||
|
||
db, err := OpenDB(dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer db.conn.Close()
|
||
|
||
store := NewPacketStore(db, &PacketStoreConfig{MaxMemoryMB: 100})
|
||
if err := store.Load(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
result := store.computeAnalyticsTopology("", "", TimeWindow{})
|
||
topRepeaters := result["topRepeaters"].([]map[string]interface{})
|
||
|
||
// "ab" is ambiguous — should NOT be merged with "ab1122"
|
||
// We expect two separate entries: one for "ab" (count=10) and one for "ab1122" (count=5)
|
||
foundAb := false
|
||
foundAb1122 := false
|
||
for _, entry := range topRepeaters {
|
||
hop := entry["hop"].(string)
|
||
count := entry["count"].(int)
|
||
if hop == "ab" {
|
||
foundAb = true
|
||
if count != 10 {
|
||
t.Errorf("ambiguous hop 'ab' should have count=10, got %d", count)
|
||
}
|
||
}
|
||
if hop == "ab1122" {
|
||
foundAb1122 = true
|
||
if count != 5 {
|
||
t.Errorf("unambiguous hop 'ab1122' should have count=5, got %d", count)
|
||
}
|
||
}
|
||
}
|
||
if !foundAb {
|
||
t.Error("ambiguous hop 'ab' should remain as separate entry")
|
||
}
|
||
if !foundAb1122 {
|
||
t.Error("unambiguous hop 'ab1122' should remain as separate entry (not merged with ambiguous 'ab')")
|
||
}
|
||
}
|
||
|
||
// TestTopologyDedup_PairsMergeByPubkey verifies that topPairs merges
|
||
// pair entries whose hops resolve unambiguously to the same node pair.
|
||
func TestTopologyDedup_PairsMergeByPubkey(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dbPath := filepath.Join(dir, "test.db")
|
||
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer conn.Close()
|
||
|
||
exec := func(s string) {
|
||
if _, err := conn.Exec(s); err != nil {
|
||
t.Fatalf("SQL exec failed: %v\nSQL: %s", err, s)
|
||
}
|
||
}
|
||
exec(`CREATE TABLE transmissions (
|
||
id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT,
|
||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT
|
||
)`)
|
||
exec(`CREATE TABLE observations (
|
||
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
|
||
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||
)`)
|
||
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||
exec(`CREATE TABLE nodes (
|
||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
|
||
last_seen TEXT, frequency REAL
|
||
)`)
|
||
exec(`CREATE TABLE schema_version (version INTEGER)`)
|
||
exec(`INSERT INTO schema_version (version) VALUES (1)`)
|
||
exec(`CREATE INDEX idx_tx_first_seen ON transmissions(first_seen)`)
|
||
|
||
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('0735bc6dda4d1122aabbccdd', 'AQUA', 'Repeater')`)
|
||
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('99aabb001122334455667788', 'BETA', 'Repeater')`)
|
||
|
||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||
txID := 1
|
||
obsID := 1
|
||
insertTx := func(path string, count int) {
|
||
for i := 0; i < count; i++ {
|
||
ts := base.Add(time.Duration(txID) * time.Minute).Format(time.RFC3339)
|
||
hash := fmt.Sprintf("h%04d", txID)
|
||
conn.Exec("INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?, ?, ?, ?, 0, 4, 1, ?)",
|
||
txID, "aabb", hash, ts, fmt.Sprintf(`{"pubKey":"pk%04d"}`, txID))
|
||
conn.Exec("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
obsID, txID, "obs1", "Obs1", "RX", -10.0, -80.0, 5, path, ts)
|
||
txID++
|
||
obsID++
|
||
}
|
||
}
|
||
|
||
// Path ["07","99aa"] → pair "07|99aa", 10 times
|
||
// Path ["0735bc","99"] → pair "0735bc|99" but sorted = "0735bc|99", 5 times
|
||
// Wait: pair sorting is by string comparison: "07" < "99aa", "0735bc" < "99"
|
||
// After dedup both should merge to AQUA|BETA pair with count=15
|
||
insertTx(`["07","99aa"]`, 10)
|
||
insertTx(`["0735bc","99"]`, 5)
|
||
|
||
db, err := OpenDB(dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer db.conn.Close()
|
||
|
||
store := NewPacketStore(db, &PacketStoreConfig{MaxMemoryMB: 100})
|
||
if err := store.Load(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
result := store.computeAnalyticsTopology("", "", TimeWindow{})
|
||
topPairs := result["topPairs"].([]map[string]interface{})
|
||
|
||
// Should have exactly 1 pair entry for AQUA-BETA with count=15
|
||
aquaBetaPairs := 0
|
||
totalCount := 0
|
||
for _, entry := range topPairs {
|
||
pkA, _ := entry["pubkeyA"].(string)
|
||
pkB, _ := entry["pubkeyB"].(string)
|
||
if (pkA == "0735bc6dda4d1122aabbccdd" && pkB == "99aabb001122334455667788") ||
|
||
(pkA == "99aabb001122334455667788" && pkB == "0735bc6dda4d1122aabbccdd") {
|
||
aquaBetaPairs++
|
||
totalCount += entry["count"].(int)
|
||
}
|
||
}
|
||
|
||
if aquaBetaPairs != 1 {
|
||
t.Errorf("AQUA-BETA pair should appear exactly once after dedup, got %d entries", aquaBetaPairs)
|
||
for _, e := range topPairs {
|
||
t.Logf(" pair: hopA=%v hopB=%v count=%v pkA=%v pkB=%v", e["hopA"], e["hopB"], e["count"], e["pubkeyA"], e["pubkeyB"])
|
||
}
|
||
}
|
||
if totalCount != 15 {
|
||
t.Errorf("AQUA-BETA pair total count should be 15, got %d", totalCount)
|
||
}
|
||
}
|