mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-20 23:00:58 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f265b312d | ||
|
|
5a959093fe | ||
|
|
d259076285 | ||
|
|
6dc4a21a1f | ||
|
|
507ed19d0e | ||
|
|
0c93c2f548 | ||
|
|
412a8fdb8f | ||
|
|
9a39198d92 | ||
|
|
526ea8a1fc | ||
|
|
8e42febc9c | ||
|
|
59bff5462c | ||
|
|
8c1cd8a9fe | ||
|
|
29e8e37114 | ||
|
|
9b9f396af5 | ||
|
|
b472c8de30 | ||
|
|
03e384bbc4 | ||
|
|
bf8c9e72ec | ||
|
|
48923db3d0 | ||
|
|
709e5a4776 |
@@ -362,6 +362,12 @@ One logical change per commit. Each commit is deployable. Each commit has its te
|
|||||||
- Tests: `test-{feature}.js` in repo root
|
- Tests: `test-{feature}.js` in repo root
|
||||||
- No build step, no transpilation — write ES2020 for server, ES5/6 for frontend (broad browser support)
|
- No build step, no transpilation — write ES2020 for server, ES5/6 for frontend (broad browser support)
|
||||||
|
|
||||||
|
### Deep Linking
|
||||||
|
All new UI states that a user might want to share or bookmark MUST be reflected in the URL hash.
|
||||||
|
This includes: tabs, filters, selected items, view modes. Use query parameters on the hash
|
||||||
|
(e.g., `#/packets?observer=ABC&timeRange=24h`) for filter state.
|
||||||
|
Existing patterns: `#/nodes/{pubkey}?section=node-neighbors`, `#/analytics?tab=collisions`, `#/packets/{hash}`.
|
||||||
|
|
||||||
## What NOT to Do
|
## What NOT to Do
|
||||||
- **Don't check in private information** — no names, API keys, tokens, passwords, IP addresses, personal data, or any identifying information. This is a PUBLIC repo.
|
- **Don't check in private information** — no names, API keys, tokens, passwords, IP addresses, personal data, or any identifying information. This is a PUBLIC repo.
|
||||||
- Don't add npm dependencies without asking
|
- Don't add npm dependencies without asking
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAdvertPubkeyTracking verifies that advertPubkeys is maintained
|
||||||
|
// incrementally during ingest and eviction, and that GetPerfStoreStats
|
||||||
|
// returns the correct count without per-request JSON parsing.
|
||||||
|
func TestAdvertPubkeyTracking(t *testing.T) {
|
||||||
|
ps := NewPacketStore(nil, nil)
|
||||||
|
ps.mu.Lock()
|
||||||
|
|
||||||
|
// Helper to create an ADVERT StoreTx with a given pubkey.
|
||||||
|
pt4 := 4
|
||||||
|
mkAdvert := func(id int, pubkey string) *StoreTx {
|
||||||
|
d := map[string]interface{}{"pubKey": pubkey}
|
||||||
|
j, _ := json.Marshal(d)
|
||||||
|
return &StoreTx{
|
||||||
|
ID: id,
|
||||||
|
Hash: fmt.Sprintf("hash%d", id),
|
||||||
|
PayloadType: &pt4,
|
||||||
|
DecodedJSON: string(j),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 3 adverts: 2 distinct pubkeys
|
||||||
|
tx1 := mkAdvert(1, "pk_alpha")
|
||||||
|
tx2 := mkAdvert(2, "pk_beta")
|
||||||
|
tx3 := mkAdvert(3, "pk_alpha") // duplicate pubkey
|
||||||
|
|
||||||
|
for _, tx := range []*StoreTx{tx1, tx2, tx3} {
|
||||||
|
ps.packets = append(ps.packets, tx)
|
||||||
|
ps.byHash[tx.Hash] = tx
|
||||||
|
ps.byTxID[tx.ID] = tx
|
||||||
|
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
|
||||||
|
ps.trackAdvertPubkey(tx)
|
||||||
|
}
|
||||||
|
ps.mu.Unlock()
|
||||||
|
|
||||||
|
// GetPerfStoreStats should report 2 distinct pubkeys
|
||||||
|
stats := ps.GetPerfStoreStats()
|
||||||
|
indexes := stats["indexes"].(map[string]interface{})
|
||||||
|
got := indexes["advertByObserver"].(int)
|
||||||
|
if got != 2 {
|
||||||
|
t.Errorf("advertByObserver = %d, want 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPerfStoreStatsTyped should agree
|
||||||
|
typed := ps.GetPerfStoreStatsTyped()
|
||||||
|
if typed.Indexes.AdvertByObserver != 2 {
|
||||||
|
t.Errorf("typed AdvertByObserver = %d, want 2", typed.Indexes.AdvertByObserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict tx3 (pk_alpha duplicate) — count should stay 2
|
||||||
|
ps.mu.Lock()
|
||||||
|
ps.untrackAdvertPubkey(tx3)
|
||||||
|
ps.mu.Unlock()
|
||||||
|
|
||||||
|
stats2 := ps.GetPerfStoreStats()
|
||||||
|
idx2 := stats2["indexes"].(map[string]interface{})
|
||||||
|
if idx2["advertByObserver"].(int) != 2 {
|
||||||
|
t.Errorf("after evicting duplicate: advertByObserver = %d, want 2", idx2["advertByObserver"].(int))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict tx1 (last pk_alpha) — count should drop to 1
|
||||||
|
ps.mu.Lock()
|
||||||
|
ps.untrackAdvertPubkey(tx1)
|
||||||
|
ps.mu.Unlock()
|
||||||
|
|
||||||
|
stats3 := ps.GetPerfStoreStats()
|
||||||
|
idx3 := stats3["indexes"].(map[string]interface{})
|
||||||
|
if idx3["advertByObserver"].(int) != 1 {
|
||||||
|
t.Errorf("after evicting last pk_alpha: advertByObserver = %d, want 1", idx3["advertByObserver"].(int))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict tx2 (last remaining) — count should be 0
|
||||||
|
ps.mu.Lock()
|
||||||
|
ps.untrackAdvertPubkey(tx2)
|
||||||
|
ps.mu.Unlock()
|
||||||
|
|
||||||
|
stats4 := ps.GetPerfStoreStats()
|
||||||
|
idx4 := stats4["indexes"].(map[string]interface{})
|
||||||
|
if idx4["advertByObserver"].(int) != 0 {
|
||||||
|
t.Errorf("after evicting all: advertByObserver = %d, want 0", idx4["advertByObserver"].(int))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdvertPubkeyPublicKeyField tests the "public_key" JSON field variant.
|
||||||
|
func TestAdvertPubkeyPublicKeyField(t *testing.T) {
|
||||||
|
ps := NewPacketStore(nil, nil)
|
||||||
|
ps.mu.Lock()
|
||||||
|
pt4 := 4
|
||||||
|
d, _ := json.Marshal(map[string]interface{}{"public_key": "pk_legacy"})
|
||||||
|
tx := &StoreTx{ID: 1, Hash: "h1", PayloadType: &pt4, DecodedJSON: string(d)}
|
||||||
|
ps.trackAdvertPubkey(tx)
|
||||||
|
ps.mu.Unlock()
|
||||||
|
|
||||||
|
stats := ps.GetPerfStoreStats()
|
||||||
|
idx := stats["indexes"].(map[string]interface{})
|
||||||
|
if idx["advertByObserver"].(int) != 1 {
|
||||||
|
t.Errorf("public_key field: advertByObserver = %d, want 1", idx["advertByObserver"].(int))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdvertPubkeyNonAdvert ensures non-ADVERT packets don't affect the count.
|
||||||
|
func TestAdvertPubkeyNonAdvert(t *testing.T) {
|
||||||
|
ps := NewPacketStore(nil, nil)
|
||||||
|
ps.mu.Lock()
|
||||||
|
pt2 := 2
|
||||||
|
d, _ := json.Marshal(map[string]interface{}{"pubKey": "pk_text"})
|
||||||
|
tx := &StoreTx{ID: 1, Hash: "h1", PayloadType: &pt2, DecodedJSON: string(d)}
|
||||||
|
ps.trackAdvertPubkey(tx)
|
||||||
|
ps.mu.Unlock()
|
||||||
|
|
||||||
|
stats := ps.GetPerfStoreStats()
|
||||||
|
idx := stats["indexes"].(map[string]interface{})
|
||||||
|
if idx["advertByObserver"].(int) != 0 {
|
||||||
|
t.Errorf("non-ADVERT should not be tracked: advertByObserver = %d, want 0", idx["advertByObserver"].(int))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkGetPerfStoreStats benchmarks the perf stats endpoint with many adverts.
|
||||||
|
// Before the fix, this did O(N) JSON unmarshals per call.
|
||||||
|
// After the fix, it's O(1) — just len(map).
|
||||||
|
func BenchmarkGetPerfStoreStats(b *testing.B) {
|
||||||
|
ps := NewPacketStore(nil, nil)
|
||||||
|
ps.mu.Lock()
|
||||||
|
pt4 := 4
|
||||||
|
for i := 0; i < 5000; i++ {
|
||||||
|
pk := fmt.Sprintf("pk_%04d", i%200) // 200 distinct pubkeys
|
||||||
|
d, _ := json.Marshal(map[string]interface{}{"pubKey": pk})
|
||||||
|
tx := &StoreTx{
|
||||||
|
ID: i + 1,
|
||||||
|
Hash: fmt.Sprintf("hash%d", i+1),
|
||||||
|
PayloadType: &pt4,
|
||||||
|
DecodedJSON: string(d),
|
||||||
|
}
|
||||||
|
ps.packets = append(ps.packets, tx)
|
||||||
|
ps.byHash[tx.Hash] = tx
|
||||||
|
ps.byTxID[tx.ID] = tx
|
||||||
|
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
|
||||||
|
ps.trackAdvertPubkey(tx)
|
||||||
|
}
|
||||||
|
ps.mu.Unlock()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
ps.GetPerfStoreStats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkGetPerfStoreStatsTyped benchmarks the typed variant.
|
||||||
|
func BenchmarkGetPerfStoreStatsTyped(b *testing.B) {
|
||||||
|
ps := NewPacketStore(nil, nil)
|
||||||
|
ps.mu.Lock()
|
||||||
|
pt4 := 4
|
||||||
|
for i := 0; i < 5000; i++ {
|
||||||
|
pk := fmt.Sprintf("pk_%04d", i%200)
|
||||||
|
d, _ := json.Marshal(map[string]interface{}{"pubKey": pk})
|
||||||
|
tx := &StoreTx{
|
||||||
|
ID: i + 1,
|
||||||
|
Hash: fmt.Sprintf("hash%d", i+1),
|
||||||
|
PayloadType: &pt4,
|
||||||
|
DecodedJSON: string(d),
|
||||||
|
}
|
||||||
|
ps.packets = append(ps.packets, tx)
|
||||||
|
ps.byHash[tx.Hash] = tx
|
||||||
|
ps.byTxID[tx.ID] = tx
|
||||||
|
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
|
||||||
|
ps.trackAdvertPubkey(tx)
|
||||||
|
}
|
||||||
|
ps.mu.Unlock()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
ps.GetPerfStoreStatsTyped()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ func newTestStore(t *testing.T) *PacketStore {
|
|||||||
distCache: make(map[string]*cachedResult),
|
distCache: make(map[string]*cachedResult),
|
||||||
subpathCache: make(map[string]*cachedResult),
|
subpathCache: make(map[string]*cachedResult),
|
||||||
rfCacheTTL: 15 * time.Second,
|
rfCacheTTL: 15 * time.Second,
|
||||||
|
invCooldown: 10 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,3 +170,164 @@ func TestInvalidateCachesFor_NoFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestInvalidationRateLimited verifies that rapid ingest cycles don't clear
|
||||||
|
// caches immediately — they accumulate dirty flags during the cooldown period
|
||||||
|
// and apply them on the next call after cooldown expires (fixes #533).
|
||||||
|
func TestInvalidationRateLimited(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
s.invCooldown = 100 * time.Millisecond // short cooldown for testing
|
||||||
|
|
||||||
|
// First invalidation should go through immediately
|
||||||
|
populateAllCaches(s)
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
||||||
|
state := cachePopulated(s)
|
||||||
|
if state["rf"] {
|
||||||
|
t.Error("rf cache should be cleared on first invalidation")
|
||||||
|
}
|
||||||
|
if !state["topo"] {
|
||||||
|
t.Error("topo cache should survive (no path changes)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repopulate and call again within cooldown — should NOT clear
|
||||||
|
populateAllCaches(s)
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
||||||
|
state = cachePopulated(s)
|
||||||
|
if !state["rf"] {
|
||||||
|
t.Error("rf cache should survive during cooldown period")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for cooldown to expire
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
|
// Next call should apply accumulated + current flags
|
||||||
|
populateAllCaches(s)
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{hasNewPaths: true})
|
||||||
|
state = cachePopulated(s)
|
||||||
|
if state["rf"] {
|
||||||
|
t.Error("rf cache should be cleared (pending from cooldown)")
|
||||||
|
}
|
||||||
|
if state["topo"] {
|
||||||
|
t.Error("topo cache should be cleared (current call has hasNewPaths)")
|
||||||
|
}
|
||||||
|
if !state["hash"] {
|
||||||
|
t.Error("hash cache should survive (no transmission changes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidationCooldownAccumulatesFlags verifies that multiple calls during
|
||||||
|
// cooldown merge their flags correctly.
|
||||||
|
func TestInvalidationCooldownAccumulatesFlags(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
s.invCooldown = 200 * time.Millisecond
|
||||||
|
|
||||||
|
// Initial invalidation (goes through, starts cooldown)
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
||||||
|
|
||||||
|
// Several calls during cooldown with different flags
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{hasNewPaths: true})
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{hasNewTransmissions: true})
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{hasChannelData: true})
|
||||||
|
|
||||||
|
// Verify pending has all flags
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
if s.pendingInv == nil {
|
||||||
|
t.Fatal("pendingInv should not be nil during cooldown")
|
||||||
|
}
|
||||||
|
if !s.pendingInv.hasNewPaths || !s.pendingInv.hasNewTransmissions || !s.pendingInv.hasChannelData {
|
||||||
|
t.Error("all flags should be accumulated in pendingInv")
|
||||||
|
}
|
||||||
|
// hasNewObservations was applied immediately, not accumulated
|
||||||
|
if s.pendingInv.hasNewObservations {
|
||||||
|
t.Error("hasNewObservations was already applied, should not be in pending")
|
||||||
|
}
|
||||||
|
s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
// Wait for cooldown, then trigger — all accumulated flags should apply
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
populateAllCaches(s)
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{}) // empty trigger
|
||||||
|
state := cachePopulated(s)
|
||||||
|
|
||||||
|
// Pending had paths, transmissions, channels — all those caches should clear
|
||||||
|
if state["topo"] {
|
||||||
|
t.Error("topo should be cleared (pending hasNewPaths)")
|
||||||
|
}
|
||||||
|
if state["hash"] {
|
||||||
|
t.Error("hash should be cleared (pending hasNewTransmissions)")
|
||||||
|
}
|
||||||
|
if state["chan"] {
|
||||||
|
t.Error("chan should be cleared (pending hasChannelData)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvictionBypassesCooldown verifies eviction always clears immediately.
|
||||||
|
func TestEvictionBypassesCooldown(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
s.invCooldown = 10 * time.Second // long cooldown
|
||||||
|
|
||||||
|
// Start cooldown
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
||||||
|
|
||||||
|
// Eviction during cooldown should still clear everything
|
||||||
|
populateAllCaches(s)
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{eviction: true})
|
||||||
|
state := cachePopulated(s)
|
||||||
|
for name, has := range state {
|
||||||
|
if has {
|
||||||
|
t.Errorf("%s cache should be cleared on eviction even during cooldown", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// pendingInv should be cleared
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
if s.pendingInv != nil {
|
||||||
|
t.Error("pendingInv should be nil after eviction")
|
||||||
|
}
|
||||||
|
s.cacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkCacheHitDuringIngestion simulates rapid ingestion and verifies
|
||||||
|
// that cache hits now occur thanks to rate-limited invalidation.
|
||||||
|
func BenchmarkCacheHitDuringIngestion(b *testing.B) {
|
||||||
|
s := &PacketStore{
|
||||||
|
rfCache: make(map[string]*cachedResult),
|
||||||
|
topoCache: make(map[string]*cachedResult),
|
||||||
|
hashCache: make(map[string]*cachedResult),
|
||||||
|
chanCache: make(map[string]*cachedResult),
|
||||||
|
distCache: make(map[string]*cachedResult),
|
||||||
|
subpathCache: make(map[string]*cachedResult),
|
||||||
|
rfCacheTTL: 15 * time.Second,
|
||||||
|
invCooldown: 50 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger first invalidation to start cooldown timer
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
||||||
|
|
||||||
|
var hits, misses int64
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
// Populate cache (simulates an analytics query filling the cache)
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
s.rfCache["global"] = &cachedResult{
|
||||||
|
data: map[string]interface{}{"test": true},
|
||||||
|
expiresAt: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
// Simulate rapid ingest invalidation (should be rate-limited)
|
||||||
|
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
||||||
|
|
||||||
|
// Check if cache survived the invalidation
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
if len(s.rfCache) > 0 {
|
||||||
|
hits++
|
||||||
|
} else {
|
||||||
|
misses++
|
||||||
|
}
|
||||||
|
s.cacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if hits == 0 {
|
||||||
|
b.Errorf("expected cache hits > 0 with rate-limited invalidation, got 0 hits / %d misses", misses)
|
||||||
|
}
|
||||||
|
b.ReportMetric(float64(hits)/float64(hits+misses)*100, "hit%")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3811,3 +3811,105 @@ func BenchmarkIndexByNode(b *testing.B) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Multi-observer comma-separated filter tests ---
|
||||||
|
|
||||||
|
func TestTransmissionsForObserverMultiCSV(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
seedTestData(t, db)
|
||||||
|
store := NewPacketStore(db, nil)
|
||||||
|
store.Load()
|
||||||
|
|
||||||
|
t.Run("comma-separated returns union via index", func(t *testing.T) {
|
||||||
|
result := store.transmissionsForObserver("obs1,obs2", nil)
|
||||||
|
if len(result) == 0 {
|
||||||
|
t.Fatal("expected results for obs1,obs2")
|
||||||
|
}
|
||||||
|
// obs1 has transmissions 1,2,3; obs2 has transmission 1
|
||||||
|
// Union should include all unique transmissions
|
||||||
|
obs1Only := store.transmissionsForObserver("obs1", nil)
|
||||||
|
obs2Only := store.transmissionsForObserver("obs2", nil)
|
||||||
|
if len(result) < len(obs1Only) || len(result) < len(obs2Only) {
|
||||||
|
t.Errorf("union (%d) should be >= each individual set (obs1=%d, obs2=%d)",
|
||||||
|
len(result), len(obs1Only), len(obs2Only))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("comma-separated with spaces via index", func(t *testing.T) {
|
||||||
|
result := store.transmissionsForObserver("obs1, obs2", nil)
|
||||||
|
if len(result) == 0 {
|
||||||
|
t.Fatal("expected results for 'obs1, obs2' (with space)")
|
||||||
|
}
|
||||||
|
noSpace := store.transmissionsForObserver("obs1,obs2", nil)
|
||||||
|
if len(result) != len(noSpace) {
|
||||||
|
t.Errorf("with-space (%d) should equal no-space (%d)", len(result), len(noSpace))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("comma-separated returns union via filter path", func(t *testing.T) {
|
||||||
|
allTx := store.packets
|
||||||
|
result := store.transmissionsForObserver("obs1,obs2", allTx)
|
||||||
|
if len(result) == 0 {
|
||||||
|
t.Fatal("expected results for obs1,obs2 via filter path")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("comma-separated with spaces via filter path", func(t *testing.T) {
|
||||||
|
allTx := store.packets
|
||||||
|
withSpace := store.transmissionsForObserver("obs1, obs2", allTx)
|
||||||
|
noSpace := store.transmissionsForObserver("obs1,obs2", allTx)
|
||||||
|
if len(withSpace) != len(noSpace) {
|
||||||
|
t.Errorf("filter path: with-space (%d) should equal no-space (%d)", len(withSpace), len(noSpace))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTransmissionWhereMultiObserver(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
seedTestData(t, db)
|
||||||
|
|
||||||
|
t.Run("comma-separated produces IN clause", func(t *testing.T) {
|
||||||
|
q := PacketQuery{Observer: "obs1,obs2"}
|
||||||
|
where, args := db.buildTransmissionWhere(q)
|
||||||
|
if len(where) != 1 {
|
||||||
|
t.Fatalf("expected 1 WHERE clause, got %d", len(where))
|
||||||
|
}
|
||||||
|
clause := where[0]
|
||||||
|
if !strings.Contains(clause, "IN (?,?)") {
|
||||||
|
t.Errorf("expected IN (?,?) in clause, got: %s", clause)
|
||||||
|
}
|
||||||
|
if len(args) != 2 {
|
||||||
|
t.Fatalf("expected 2 args, got %d", len(args))
|
||||||
|
}
|
||||||
|
if args[0] != "obs1" || args[1] != "obs2" {
|
||||||
|
t.Errorf("expected [obs1, obs2], got %v", args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("comma-separated with spaces trims IDs", func(t *testing.T) {
|
||||||
|
q := PacketQuery{Observer: "obs1, obs2"}
|
||||||
|
_, args := db.buildTransmissionWhere(q)
|
||||||
|
if len(args) != 2 {
|
||||||
|
t.Fatalf("expected 2 args, got %d", len(args))
|
||||||
|
}
|
||||||
|
if args[0] != "obs1" || args[1] != "obs2" {
|
||||||
|
t.Errorf("expected trimmed [obs1, obs2], got %v", args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("single observer still works", func(t *testing.T) {
|
||||||
|
q := PacketQuery{Observer: "obs1"}
|
||||||
|
where, args := db.buildTransmissionWhere(q)
|
||||||
|
if len(where) != 1 {
|
||||||
|
t.Fatalf("expected 1 WHERE clause, got %d", len(where))
|
||||||
|
}
|
||||||
|
if !strings.Contains(where[0], "IN (?)") {
|
||||||
|
t.Errorf("expected IN (?) for single observer, got: %s", where[0])
|
||||||
|
}
|
||||||
|
if len(args) != 1 || args[0] != "obs1" {
|
||||||
|
t.Errorf("expected [obs1], got %v", args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+8
-3
@@ -608,12 +608,17 @@ func (db *DB) buildTransmissionWhere(q PacketQuery) ([]string, []interface{}) {
|
|||||||
args = append(args, "%"+pk+"%")
|
args = append(args, "%"+pk+"%")
|
||||||
}
|
}
|
||||||
if q.Observer != "" {
|
if q.Observer != "" {
|
||||||
|
ids := strings.Split(q.Observer, ",")
|
||||||
|
placeholders := strings.Repeat("?,", len(ids))
|
||||||
|
placeholders = placeholders[:len(placeholders)-1]
|
||||||
if db.isV3 {
|
if db.isV3 {
|
||||||
where = append(where, "EXISTS (SELECT 1 FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.id = ?)")
|
where = append(where, "EXISTS (SELECT 1 FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.id IN ("+placeholders+"))")
|
||||||
} else {
|
} else {
|
||||||
where = append(where, "EXISTS (SELECT 1 FROM observations oi WHERE oi.transmission_id = t.id AND oi.observer_id = ?)")
|
where = append(where, "EXISTS (SELECT 1 FROM observations oi WHERE oi.transmission_id = t.id AND oi.observer_id IN ("+placeholders+"))")
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
args = append(args, strings.TrimSpace(id))
|
||||||
}
|
}
|
||||||
args = append(args, q.Observer)
|
|
||||||
}
|
}
|
||||||
if q.Region != "" {
|
if q.Region != "" {
|
||||||
if db.isV3 {
|
if db.isV3 {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -220,6 +222,44 @@ func TestSortedCopy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSortedCopyLarge(t *testing.T) {
|
||||||
|
// Regression: verify correct sort on larger input
|
||||||
|
rng := rand.New(rand.NewSource(42))
|
||||||
|
n := 1000
|
||||||
|
input := make([]float64, n)
|
||||||
|
for i := range input {
|
||||||
|
input[i] = rng.Float64() * 1000
|
||||||
|
}
|
||||||
|
result := sortedCopy(input)
|
||||||
|
if len(result) != n {
|
||||||
|
t.Fatalf("expected %d elements, got %d", n, len(result))
|
||||||
|
}
|
||||||
|
for i := 1; i < len(result); i++ {
|
||||||
|
if result[i] < result[i-1] {
|
||||||
|
t.Fatalf("not sorted at index %d: %v > %v", i, result[i-1], result[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Original unchanged
|
||||||
|
if input[0] == result[0] && input[1] == result[1] && input[2] == result[2] {
|
||||||
|
// Could be coincidence but very unlikely with random data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkSortedCopy(b *testing.B) {
|
||||||
|
rng := rand.New(rand.NewSource(42))
|
||||||
|
for _, size := range []int{256, 1000, 10000} {
|
||||||
|
data := make([]float64, size)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = rng.Float64() * 1000
|
||||||
|
}
|
||||||
|
b.Run(fmt.Sprintf("n=%d", size), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
sortedCopy(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLastN(t *testing.T) {
|
func TestLastN(t *testing.T) {
|
||||||
arr := []map[string]interface{}{
|
arr := []map[string]interface{}{
|
||||||
{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5},
|
{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5},
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestObsDedupCorrectness verifies that the map-based dedup produces correct
|
||||||
|
// results: no duplicate observations (same observerID + pathJSON) on a single
|
||||||
|
// transmission.
|
||||||
|
func TestObsDedupCorrectness(t *testing.T) {
|
||||||
|
tx := &StoreTx{
|
||||||
|
ID: 1,
|
||||||
|
Hash: "abc123",
|
||||||
|
obsKeys: make(map[string]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 5 unique observations
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
obsID := fmt.Sprintf("obs-%d", i)
|
||||||
|
pathJSON := fmt.Sprintf(`["path-%d"]`, i)
|
||||||
|
dk := obsID + "|" + pathJSON
|
||||||
|
if tx.obsKeys[dk] {
|
||||||
|
t.Fatalf("observation %d should not be a duplicate", i)
|
||||||
|
}
|
||||||
|
tx.Observations = append(tx.Observations, &StoreObs{
|
||||||
|
ID: i,
|
||||||
|
ObserverID: obsID,
|
||||||
|
PathJSON: pathJSON,
|
||||||
|
})
|
||||||
|
tx.obsKeys[dk] = true
|
||||||
|
tx.ObservationCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if tx.ObservationCount != 5 {
|
||||||
|
t.Fatalf("expected 5 observations, got %d", tx.ObservationCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to add duplicates of each — all should be rejected
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
obsID := fmt.Sprintf("obs-%d", i)
|
||||||
|
pathJSON := fmt.Sprintf(`["path-%d"]`, i)
|
||||||
|
dk := obsID + "|" + pathJSON
|
||||||
|
if !tx.obsKeys[dk] {
|
||||||
|
t.Fatalf("observation %d should be detected as duplicate", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same observer, different path — should NOT be a duplicate
|
||||||
|
dk := "obs-0" + "|" + `["different-path"]`
|
||||||
|
if tx.obsKeys[dk] {
|
||||||
|
t.Fatal("different path should not be a duplicate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different observer, same path — should NOT be a duplicate
|
||||||
|
dk = "obs-new" + "|" + `["path-0"]`
|
||||||
|
if tx.obsKeys[dk] {
|
||||||
|
t.Fatal("different observer should not be a duplicate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestObsDedupNilMapSafety ensures obsKeys lazy init works for pre-existing
|
||||||
|
// transmissions that may not have the map initialized.
|
||||||
|
func TestObsDedupNilMapSafety(t *testing.T) {
|
||||||
|
tx := &StoreTx{ID: 1, Hash: "abc"}
|
||||||
|
// obsKeys is nil — the lazy init pattern used in IngestNewFromDB/IngestNewObservations
|
||||||
|
if tx.obsKeys == nil {
|
||||||
|
tx.obsKeys = make(map[string]bool)
|
||||||
|
}
|
||||||
|
dk := "obs1|path1"
|
||||||
|
if tx.obsKeys[dk] {
|
||||||
|
t.Fatal("should not be duplicate on empty map")
|
||||||
|
}
|
||||||
|
tx.obsKeys[dk] = true
|
||||||
|
if !tx.obsKeys[dk] {
|
||||||
|
t.Fatal("should be duplicate after insert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkObsDedupMap benchmarks the map-based O(1) dedup approach.
|
||||||
|
func BenchmarkObsDedupMap(b *testing.B) {
|
||||||
|
for _, obsCount := range []int{10, 50, 100, 500} {
|
||||||
|
b.Run(fmt.Sprintf("obs=%d", obsCount), func(b *testing.B) {
|
||||||
|
// Pre-populate a tx with obsCount observations
|
||||||
|
tx := &StoreTx{
|
||||||
|
ID: 1,
|
||||||
|
obsKeys: make(map[string]bool),
|
||||||
|
}
|
||||||
|
for i := 0; i < obsCount; i++ {
|
||||||
|
obsID := fmt.Sprintf("obs-%d", i)
|
||||||
|
pathJSON := fmt.Sprintf(`["hop-%d"]`, i)
|
||||||
|
dk := obsID + "|" + pathJSON
|
||||||
|
tx.Observations = append(tx.Observations, &StoreObs{
|
||||||
|
ObserverID: obsID,
|
||||||
|
PathJSON: pathJSON,
|
||||||
|
})
|
||||||
|
tx.obsKeys[dk] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark: check dedup for a new observation (not duplicate)
|
||||||
|
newDK := "new-obs|new-path"
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = tx.obsKeys[newDK]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkObsDedupLinear benchmarks the old O(n) linear scan for comparison.
|
||||||
|
func BenchmarkObsDedupLinear(b *testing.B) {
|
||||||
|
for _, obsCount := range []int{10, 50, 100, 500} {
|
||||||
|
b.Run(fmt.Sprintf("obs=%d", obsCount), func(b *testing.B) {
|
||||||
|
tx := &StoreTx{ID: 1}
|
||||||
|
for i := 0; i < obsCount; i++ {
|
||||||
|
tx.Observations = append(tx.Observations, &StoreObs{
|
||||||
|
ObserverID: fmt.Sprintf("obs-%d", i),
|
||||||
|
PathJSON: fmt.Sprintf(`["hop-%d"]`, i),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
newObsID := "new-obs"
|
||||||
|
newPath := "new-path"
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
for _, existing := range tx.Observations {
|
||||||
|
if existing.ObserverID == newObsID && existing.PathJSON == newPath {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1958,13 +1958,7 @@ func percentile(sorted []float64, p float64) float64 {
|
|||||||
func sortedCopy(arr []float64) []float64 {
|
func sortedCopy(arr []float64) []float64 {
|
||||||
cp := make([]float64, len(arr))
|
cp := make([]float64, len(arr))
|
||||||
copy(cp, arr)
|
copy(cp, arr)
|
||||||
for i := 0; i < len(cp); i++ {
|
sort.Float64s(cp)
|
||||||
for j := i + 1; j < len(cp); j++ {
|
|
||||||
if cp[j] < cp[i] {
|
|
||||||
cp[i], cp[j] = cp[j], cp[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cp
|
return cp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3059,11 +3059,11 @@ func TestHashCollisionsWithCollision(t *testing.T) {
|
|||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||||
|
|
||||||
// Two nodes with same first byte 'CC', no adverts so hash_size=0 (included in all buckets)
|
// Two repeater nodes with same first byte 'CC' and hash_size=1
|
||||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||||
VALUES ('CC11223344556677', 'Node1', 'repeater', 37.5, -122.0, ?, '2026-01-01T00:00:00Z', 0)`, recent)
|
VALUES ('CC11223344556677', 'Node1', 'repeater', 37.5, -122.0, ?, '2026-01-01T00:00:00Z', 5)`, recent)
|
||||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||||
VALUES ('CC99887766554433', 'Node2', 'repeater', 37.51, -122.01, ?, '2026-01-01T00:00:00Z', 0)`, recent)
|
VALUES ('CC99887766554433', 'Node2', 'repeater', 37.51, -122.01, ?, '2026-01-01T00:00:00Z', 5)`, recent)
|
||||||
|
|
||||||
cfg := &Config{Port: 3000}
|
cfg := &Config{Port: 3000}
|
||||||
hub := NewHub()
|
hub := NewHub()
|
||||||
@@ -3072,6 +3072,14 @@ func TestHashCollisionsWithCollision(t *testing.T) {
|
|||||||
if err := store.Load(); err != nil {
|
if err := store.Load(); err != nil {
|
||||||
t.Fatalf("store.Load failed: %v", err)
|
t.Fatalf("store.Load failed: %v", err)
|
||||||
}
|
}
|
||||||
|
// Inject hash_size=1 for both nodes so they appear in the 1-byte bucket
|
||||||
|
store.hashSizeInfoMu.Lock()
|
||||||
|
store.hashSizeInfoCache = map[string]*hashSizeNodeInfo{
|
||||||
|
"CC11223344556677": {HashSize: 1, AllSizes: map[int]bool{1: true}},
|
||||||
|
"CC99887766554433": {HashSize: 1, AllSizes: map[int]bool{1: true}},
|
||||||
|
}
|
||||||
|
store.hashSizeInfoAt = time.Now()
|
||||||
|
store.hashSizeInfoMu.Unlock()
|
||||||
srv.store = store
|
srv.store = store
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
srv.RegisterRoutes(router)
|
srv.RegisterRoutes(router)
|
||||||
@@ -3186,3 +3194,86 @@ func TestHashCollisionsMissingCoordinates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHashCollisionsOnlyRepeaters verifies that only repeater nodes
|
||||||
|
// are included in collision analysis. Companions, rooms, sensors, and
|
||||||
|
// hash_size==0 nodes are excluded — per firmware analysis, only repeaters
|
||||||
|
// forward packets and appear in path[] arrays. (#441)
|
||||||
|
func TestHashCollisionsOnlyRepeaters(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Insert nodes sharing the same 1-byte prefix "AA":
|
||||||
|
// 1. repeater with hash_size=1 → should be counted
|
||||||
|
// 2. repeater with hash_size=0 (unknown) → should be excluded
|
||||||
|
// 3. companion with hash_size=1 → should be excluded
|
||||||
|
// 4. room with hash_size=1 → should be excluded
|
||||||
|
// 5. sensor with hash_size=1 → should be excluded
|
||||||
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen) VALUES
|
||||||
|
('aa11223344556677', 'Repeater1', 'repeater', ?),
|
||||||
|
('aa99887766554433', 'UnknownNode', 'repeater', ?),
|
||||||
|
('aadeadbeefcafe01', 'Companion1', 'companion', ?),
|
||||||
|
('aabbcc1122334455', 'Room1', 'room', ?),
|
||||||
|
('aabbcc9988776655', 'Sensor1', 'sensor', ?)`, now, now, now, now, now)
|
||||||
|
|
||||||
|
// We also need a second repeater with hash_size=1 and same prefix to
|
||||||
|
// confirm that genuine collisions ARE still detected.
|
||||||
|
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen) VALUES
|
||||||
|
('aa00112233445566', 'Repeater2', 'repeater', ?)`, now)
|
||||||
|
|
||||||
|
cfg := &Config{Port: 3000}
|
||||||
|
hub := NewHub()
|
||||||
|
srv := NewServer(db, cfg, hub)
|
||||||
|
store := NewPacketStore(db, nil)
|
||||||
|
store.Load()
|
||||||
|
srv.store = store
|
||||||
|
|
||||||
|
// Inject hash size info directly into the cache
|
||||||
|
store.hashSizeInfoMu.Lock()
|
||||||
|
store.hashSizeInfoCache = map[string]*hashSizeNodeInfo{
|
||||||
|
"aa11223344556677": {HashSize: 1, AllSizes: map[int]bool{1: true}},
|
||||||
|
"aa00112233445566": {HashSize: 1, AllSizes: map[int]bool{1: true}},
|
||||||
|
"aa99887766554433": {HashSize: 0, AllSizes: map[int]bool{}}, // unknown
|
||||||
|
"aadeadbeefcafe01": {HashSize: 1, AllSizes: map[int]bool{1: true}}, // companion
|
||||||
|
"aabbcc1122334455": {HashSize: 1, AllSizes: map[int]bool{1: true}}, // room
|
||||||
|
"aabbcc9988776655": {HashSize: 1, AllSizes: map[int]bool{1: true}}, // sensor
|
||||||
|
}
|
||||||
|
store.hashSizeInfoAt = time.Now()
|
||||||
|
store.hashSizeInfoMu.Unlock()
|
||||||
|
|
||||||
|
result := store.computeHashCollisions("")
|
||||||
|
|
||||||
|
bySize, ok := result["by_size"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing by_size")
|
||||||
|
}
|
||||||
|
|
||||||
|
size1, ok := bySize["1"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing by_size[1]")
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, ok := size1["stats"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing stats")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only Repeater1 and Repeater2 should be in nodesForByte (hash_size=1, role=repeater).
|
||||||
|
// UnknownNode (hash_size=0), Companion1, Room1, Sensor1 must all be excluded.
|
||||||
|
nodesForByte := stats["nodes_for_byte"]
|
||||||
|
if nodesForByte != 2 {
|
||||||
|
t.Errorf("expected nodes_for_byte=2 (only repeaters with hash_size=1), got %v", nodesForByte)
|
||||||
|
}
|
||||||
|
|
||||||
|
// They share prefix "AA", so there should be exactly 1 collision entry.
|
||||||
|
collisions, ok := size1["collisions"].([]collisionEntry)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("collisions is not []collisionEntry")
|
||||||
|
}
|
||||||
|
if len(collisions) != 1 {
|
||||||
|
t.Errorf("expected 1 collision entry, got %d", len(collisions))
|
||||||
|
}
|
||||||
|
if len(collisions) == 1 && len(collisions[0].Nodes) != 2 {
|
||||||
|
t.Errorf("expected 2 nodes in collision, got %d", len(collisions[0].Nodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+146
-95
@@ -43,6 +43,8 @@ type StoreTx struct {
|
|||||||
// Cached parsed fields (set once, read many)
|
// Cached parsed fields (set once, read many)
|
||||||
parsedPath []string // cached parsePathJSON result
|
parsedPath []string // cached parsePathJSON result
|
||||||
pathParsed bool // whether parsedPath has been set
|
pathParsed bool // whether parsedPath has been set
|
||||||
|
// Dedup map: "observerID|pathJSON" → true for O(1) duplicate checks
|
||||||
|
obsKeys map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreObs is a lean in-memory observation (no duplication of transmission fields).
|
// StoreObs is a lean in-memory observation (no duplication of transmission fields).
|
||||||
@@ -88,6 +90,10 @@ type PacketStore struct {
|
|||||||
collisionCacheTTL time.Duration
|
collisionCacheTTL time.Duration
|
||||||
cacheHits int64
|
cacheHits int64
|
||||||
cacheMisses int64
|
cacheMisses int64
|
||||||
|
// Rate-limited invalidation (fixes #533: caches cleared faster than hit)
|
||||||
|
lastInvalidated time.Time
|
||||||
|
pendingInv *cacheInvalidation // accumulated dirty flags during cooldown
|
||||||
|
invCooldown time.Duration // minimum time between invalidations
|
||||||
// Short-lived cache for QueryGroupedPackets (avoids repeated full sort)
|
// Short-lived cache for QueryGroupedPackets (avoids repeated full sort)
|
||||||
groupedCacheMu sync.Mutex
|
groupedCacheMu sync.Mutex
|
||||||
groupedCacheKey string
|
groupedCacheKey string
|
||||||
@@ -117,6 +123,10 @@ type PacketStore struct {
|
|||||||
hashSizeInfoCache map[string]*hashSizeNodeInfo
|
hashSizeInfoCache map[string]*hashSizeNodeInfo
|
||||||
hashSizeInfoAt time.Time
|
hashSizeInfoAt time.Time
|
||||||
|
|
||||||
|
// Precomputed distinct advert pubkey count (refcounted for eviction correctness).
|
||||||
|
// Updated incrementally during Load/Ingest/Evict — avoids JSON parsing in GetPerfStoreStats.
|
||||||
|
advertPubkeys map[string]int // pubkey → number of advert packets referencing it
|
||||||
|
|
||||||
// Eviction config and stats
|
// Eviction config and stats
|
||||||
retentionHours float64 // 0 = unlimited
|
retentionHours float64 // 0 = unlimited
|
||||||
maxMemoryMB int // 0 = unlimited
|
maxMemoryMB int // 0 = unlimited
|
||||||
@@ -182,7 +192,9 @@ func NewPacketStore(db *DB, cfg *PacketStoreConfig) *PacketStore {
|
|||||||
subpathCache: make(map[string]*cachedResult),
|
subpathCache: make(map[string]*cachedResult),
|
||||||
rfCacheTTL: 15 * time.Second,
|
rfCacheTTL: 15 * time.Second,
|
||||||
collisionCacheTTL: 60 * time.Second,
|
collisionCacheTTL: 60 * time.Second,
|
||||||
|
invCooldown: 10 * time.Second,
|
||||||
spIndex: make(map[string]int, 4096),
|
spIndex: make(map[string]int, 4096),
|
||||||
|
advertPubkeys: make(map[string]int),
|
||||||
}
|
}
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
ps.retentionHours = cfg.RetentionHours
|
ps.retentionHours = cfg.RetentionHours
|
||||||
@@ -253,6 +265,7 @@ func (s *PacketStore) Load() error {
|
|||||||
RouteType: nullIntPtr(routeType),
|
RouteType: nullIntPtr(routeType),
|
||||||
PayloadType: nullIntPtr(payloadType),
|
PayloadType: nullIntPtr(payloadType),
|
||||||
DecodedJSON: nullStrVal(decodedJSON),
|
DecodedJSON: nullStrVal(decodedJSON),
|
||||||
|
obsKeys: make(map[string]bool),
|
||||||
}
|
}
|
||||||
s.byHash[hashStr] = tx
|
s.byHash[hashStr] = tx
|
||||||
s.packets = append(s.packets, tx)
|
s.packets = append(s.packets, tx)
|
||||||
@@ -262,6 +275,7 @@ func (s *PacketStore) Load() error {
|
|||||||
pt := *tx.PayloadType
|
pt := *tx.PayloadType
|
||||||
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
||||||
}
|
}
|
||||||
|
s.trackAdvertPubkey(tx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if obsID.Valid {
|
if obsID.Valid {
|
||||||
@@ -269,15 +283,9 @@ func (s *PacketStore) Load() error {
|
|||||||
obsIDStr := nullStrVal(observerID)
|
obsIDStr := nullStrVal(observerID)
|
||||||
obsPJ := nullStrVal(pathJSON)
|
obsPJ := nullStrVal(pathJSON)
|
||||||
|
|
||||||
// Dedup: skip if same observer + same path already loaded
|
// Dedup: skip if same observer + same path already loaded (O(1) map lookup)
|
||||||
isDupe := false
|
dk := obsIDStr + "|" + obsPJ
|
||||||
for _, existing := range tx.Observations {
|
if tx.obsKeys[dk] {
|
||||||
if existing.ObserverID == obsIDStr && existing.PathJSON == obsPJ {
|
|
||||||
isDupe = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isDupe {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +303,7 @@ func (s *PacketStore) Load() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tx.Observations = append(tx.Observations, obs)
|
tx.Observations = append(tx.Observations, obs)
|
||||||
|
tx.obsKeys[dk] = true
|
||||||
tx.ObservationCount++
|
tx.ObservationCount++
|
||||||
if obs.Timestamp > tx.LatestSeen {
|
if obs.Timestamp > tx.LatestSeen {
|
||||||
tx.LatestSeen = obs.Timestamp
|
tx.LatestSeen = obs.Timestamp
|
||||||
@@ -392,6 +401,52 @@ func (s *PacketStore) indexByNode(tx *StoreTx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trackAdvertPubkey increments the advertPubkeys refcount for ADVERT packets.
|
||||||
|
// Must be called under s.mu write lock.
|
||||||
|
func (s *PacketStore) trackAdvertPubkey(tx *StoreTx) {
|
||||||
|
if tx.PayloadType == nil || *tx.PayloadType != 4 || tx.DecodedJSON == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var d map[string]interface{}
|
||||||
|
if json.Unmarshal([]byte(tx.DecodedJSON), &d) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pk := ""
|
||||||
|
if v, ok := d["pubKey"].(string); ok {
|
||||||
|
pk = v
|
||||||
|
} else if v, ok := d["public_key"].(string); ok {
|
||||||
|
pk = v
|
||||||
|
}
|
||||||
|
if pk != "" {
|
||||||
|
s.advertPubkeys[pk]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// untrackAdvertPubkey decrements the advertPubkeys refcount for ADVERT packets.
|
||||||
|
// Must be called under s.mu write lock.
|
||||||
|
func (s *PacketStore) untrackAdvertPubkey(tx *StoreTx) {
|
||||||
|
if tx.PayloadType == nil || *tx.PayloadType != 4 || tx.DecodedJSON == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var d map[string]interface{}
|
||||||
|
if json.Unmarshal([]byte(tx.DecodedJSON), &d) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pk := ""
|
||||||
|
if v, ok := d["pubKey"].(string); ok {
|
||||||
|
pk = v
|
||||||
|
} else if v, ok := d["public_key"].(string); ok {
|
||||||
|
pk = v
|
||||||
|
}
|
||||||
|
if pk != "" {
|
||||||
|
if s.advertPubkeys[pk] <= 1 {
|
||||||
|
delete(s.advertPubkeys, pk)
|
||||||
|
} else {
|
||||||
|
s.advertPubkeys[pk]--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// QueryPackets returns filtered, paginated packets from memory.
|
// QueryPackets returns filtered, paginated packets from memory.
|
||||||
func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
|
func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
|
||||||
atomic.AddInt64(&s.queryCount, 1)
|
atomic.AddInt64(&s.queryCount, 1)
|
||||||
@@ -579,30 +634,8 @@ func (s *PacketStore) GetPerfStoreStats() map[string]interface{} {
|
|||||||
nodeIdx := len(s.byNode)
|
nodeIdx := len(s.byNode)
|
||||||
ptIdx := len(s.byPayloadType)
|
ptIdx := len(s.byPayloadType)
|
||||||
|
|
||||||
// Count distinct pubkeys with ADVERT observations (matches Node.js _advertByObserver.size)
|
// Distinct advert pubkey count — precomputed incrementally (see trackAdvertPubkey).
|
||||||
advertByObsCount := 0
|
advertByObsCount := len(s.advertPubkeys)
|
||||||
if adverts, ok := s.byPayloadType[4]; ok {
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
for _, tx := range adverts {
|
|
||||||
if tx.DecodedJSON == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var d map[string]interface{}
|
|
||||||
if json.Unmarshal([]byte(tx.DecodedJSON), &d) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pk := ""
|
|
||||||
if v, ok := d["pubKey"].(string); ok {
|
|
||||||
pk = v
|
|
||||||
} else if v, ok := d["public_key"].(string); ok {
|
|
||||||
pk = v
|
|
||||||
}
|
|
||||||
if pk != "" && !seen[pk] {
|
|
||||||
seen[pk] = true
|
|
||||||
advertByObsCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
// Realistic estimate: ~5KB per packet + ~500 bytes per observation
|
// Realistic estimate: ~5KB per packet + ~500 bytes per observation
|
||||||
@@ -690,15 +723,16 @@ type cacheInvalidation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// invalidateCachesFor selectively clears only the analytics caches affected
|
// invalidateCachesFor selectively clears only the analytics caches affected
|
||||||
// by the kind of data that changed. This avoids the previous behaviour of
|
// by the kind of data that changed. To prevent continuous ingestion from
|
||||||
// wiping every cache on every ingest cycle, which defeated caching under
|
// defeating caching entirely (issue #533), invalidation is rate-limited:
|
||||||
// continuous ingestion (issue #375).
|
// if called within invCooldown of the last invalidation, the flags are
|
||||||
|
// accumulated in pendingInv and applied on the next call after cooldown.
|
||||||
func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
// Eviction bypasses rate-limiting — data was removed, caches must clear.
|
||||||
if inv.eviction {
|
if inv.eviction {
|
||||||
// Eviction can affect any analytics — clear everything
|
|
||||||
s.rfCache = make(map[string]*cachedResult)
|
s.rfCache = make(map[string]*cachedResult)
|
||||||
s.topoCache = make(map[string]*cachedResult)
|
s.topoCache = make(map[string]*cachedResult)
|
||||||
s.hashCache = make(map[string]*cachedResult)
|
s.hashCache = make(map[string]*cachedResult)
|
||||||
@@ -709,9 +743,40 @@ func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
|||||||
s.channelsCacheMu.Lock()
|
s.channelsCacheMu.Lock()
|
||||||
s.channelsCacheRes = nil
|
s.channelsCacheRes = nil
|
||||||
s.channelsCacheMu.Unlock()
|
s.channelsCacheMu.Unlock()
|
||||||
|
s.lastInvalidated = time.Now()
|
||||||
|
s.pendingInv = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(s.lastInvalidated) < s.invCooldown {
|
||||||
|
// Within cooldown — accumulate dirty flags
|
||||||
|
if s.pendingInv == nil {
|
||||||
|
s.pendingInv = &cacheInvalidation{}
|
||||||
|
}
|
||||||
|
s.pendingInv.hasNewObservations = s.pendingInv.hasNewObservations || inv.hasNewObservations
|
||||||
|
s.pendingInv.hasNewPaths = s.pendingInv.hasNewPaths || inv.hasNewPaths
|
||||||
|
s.pendingInv.hasNewTransmissions = s.pendingInv.hasNewTransmissions || inv.hasNewTransmissions
|
||||||
|
s.pendingInv.hasChannelData = s.pendingInv.hasChannelData || inv.hasChannelData
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooldown expired — merge any pending flags and apply
|
||||||
|
if s.pendingInv != nil {
|
||||||
|
inv.hasNewObservations = inv.hasNewObservations || s.pendingInv.hasNewObservations
|
||||||
|
inv.hasNewPaths = inv.hasNewPaths || s.pendingInv.hasNewPaths
|
||||||
|
inv.hasNewTransmissions = inv.hasNewTransmissions || s.pendingInv.hasNewTransmissions
|
||||||
|
inv.hasChannelData = inv.hasChannelData || s.pendingInv.hasChannelData
|
||||||
|
s.pendingInv = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.applyCacheInvalidation(inv)
|
||||||
|
s.lastInvalidated = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyCacheInvalidation performs the actual cache clearing. Must be called
|
||||||
|
// with cacheMu held.
|
||||||
|
func (s *PacketStore) applyCacheInvalidation(inv cacheInvalidation) {
|
||||||
if inv.hasNewObservations {
|
if inv.hasNewObservations {
|
||||||
s.rfCache = make(map[string]*cachedResult)
|
s.rfCache = make(map[string]*cachedResult)
|
||||||
}
|
}
|
||||||
@@ -726,7 +791,6 @@ func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
|||||||
}
|
}
|
||||||
if inv.hasChannelData {
|
if inv.hasChannelData {
|
||||||
s.chanCache = make(map[string]*cachedResult)
|
s.chanCache = make(map[string]*cachedResult)
|
||||||
// Also invalidate the separate channels list cache
|
|
||||||
s.channelsCacheMu.Lock()
|
s.channelsCacheMu.Lock()
|
||||||
s.channelsCacheRes = nil
|
s.channelsCacheRes = nil
|
||||||
s.channelsCacheMu.Unlock()
|
s.channelsCacheMu.Unlock()
|
||||||
@@ -742,29 +806,7 @@ func (s *PacketStore) GetPerfStoreStatsTyped() PerfPacketStoreStats {
|
|||||||
observerIdx := len(s.byObserver)
|
observerIdx := len(s.byObserver)
|
||||||
nodeIdx := len(s.byNode)
|
nodeIdx := len(s.byNode)
|
||||||
|
|
||||||
advertByObsCount := 0
|
advertByObsCount := len(s.advertPubkeys)
|
||||||
if adverts, ok := s.byPayloadType[4]; ok {
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
for _, tx := range adverts {
|
|
||||||
if tx.DecodedJSON == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var d map[string]interface{}
|
|
||||||
if json.Unmarshal([]byte(tx.DecodedJSON), &d) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pk := ""
|
|
||||||
if v, ok := d["pubKey"].(string); ok {
|
|
||||||
pk = v
|
|
||||||
} else if v, ok := d["public_key"].(string); ok {
|
|
||||||
pk = v
|
|
||||||
}
|
|
||||||
if pk != "" && !seen[pk] {
|
|
||||||
seen[pk] = true
|
|
||||||
advertByObsCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
estimatedMB := math.Round(float64(totalLoaded*5120+totalObs*500)/1048576*10) / 10
|
estimatedMB := math.Round(float64(totalLoaded*5120+totalObs*500)/1048576*10) / 10
|
||||||
@@ -1061,6 +1103,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
|||||||
RouteType: r.routeType,
|
RouteType: r.routeType,
|
||||||
PayloadType: r.payloadType,
|
PayloadType: r.payloadType,
|
||||||
DecodedJSON: r.decodedJSON,
|
DecodedJSON: r.decodedJSON,
|
||||||
|
obsKeys: make(map[string]bool),
|
||||||
}
|
}
|
||||||
s.byHash[r.hash] = tx
|
s.byHash[r.hash] = tx
|
||||||
s.packets = append(s.packets, tx) // oldest-first; new items go to tail
|
s.packets = append(s.packets, tx) // oldest-first; new items go to tail
|
||||||
@@ -1072,6 +1115,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
|||||||
// so GetChannelMessages reverse iteration stays correct
|
// so GetChannelMessages reverse iteration stays correct
|
||||||
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
||||||
}
|
}
|
||||||
|
s.trackAdvertPubkey(tx)
|
||||||
|
|
||||||
if _, exists := broadcastTxs[r.txID]; !exists {
|
if _, exists := broadcastTxs[r.txID]; !exists {
|
||||||
broadcastTxs[r.txID] = tx
|
broadcastTxs[r.txID] = tx
|
||||||
@@ -1081,15 +1125,12 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
|||||||
|
|
||||||
if r.obsID != nil {
|
if r.obsID != nil {
|
||||||
oid := *r.obsID
|
oid := *r.obsID
|
||||||
// Dedup
|
// Dedup (O(1) map lookup)
|
||||||
isDupe := false
|
dk := r.observerID + "|" + r.pathJSON
|
||||||
for _, existing := range tx.Observations {
|
if tx.obsKeys == nil {
|
||||||
if existing.ObserverID == r.observerID && existing.PathJSON == r.pathJSON {
|
tx.obsKeys = make(map[string]bool)
|
||||||
isDupe = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if isDupe {
|
if tx.obsKeys[dk] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,6 +1147,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
|||||||
Timestamp: normalizeTimestamp(r.obsTS),
|
Timestamp: normalizeTimestamp(r.obsTS),
|
||||||
}
|
}
|
||||||
tx.Observations = append(tx.Observations, obs)
|
tx.Observations = append(tx.Observations, obs)
|
||||||
|
tx.obsKeys[dk] = true
|
||||||
tx.ObservationCount++
|
tx.ObservationCount++
|
||||||
if obs.Timestamp > tx.LatestSeen {
|
if obs.Timestamp > tx.LatestSeen {
|
||||||
tx.LatestSeen = obs.Timestamp
|
tx.LatestSeen = obs.Timestamp
|
||||||
@@ -1326,15 +1368,12 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
|||||||
continue // transmission not yet in store
|
continue // transmission not yet in store
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedup by observer + path
|
// Dedup by observer + path (O(1) map lookup)
|
||||||
isDupe := false
|
dk := r.observerID + "|" + r.pathJSON
|
||||||
for _, existing := range tx.Observations {
|
if tx.obsKeys == nil {
|
||||||
if existing.ObserverID == r.observerID && existing.PathJSON == r.pathJSON {
|
tx.obsKeys = make(map[string]bool)
|
||||||
isDupe = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if isDupe {
|
if tx.obsKeys[dk] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1351,6 +1390,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
|||||||
Timestamp: normalizeTimestamp(r.timestamp),
|
Timestamp: normalizeTimestamp(r.timestamp),
|
||||||
}
|
}
|
||||||
tx.Observations = append(tx.Observations, obs)
|
tx.Observations = append(tx.Observations, obs)
|
||||||
|
tx.obsKeys[dk] = true
|
||||||
tx.ObservationCount++
|
tx.ObservationCount++
|
||||||
if obs.Timestamp > tx.LatestSeen {
|
if obs.Timestamp > tx.LatestSeen {
|
||||||
tx.LatestSeen = obs.Timestamp
|
tx.LatestSeen = obs.Timestamp
|
||||||
@@ -1572,32 +1612,36 @@ func (s *PacketStore) filterPackets(q PacketQuery) []*StoreTx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// transmissionsForObserver returns unique transmissions for an observer.
|
// transmissionsForObserver returns unique transmissions for an observer.
|
||||||
func (s *PacketStore) transmissionsForObserver(observerID string, from []*StoreTx) []*StoreTx {
|
func (s *PacketStore) transmissionsForObserver(observerIDs string, from []*StoreTx) []*StoreTx {
|
||||||
|
ids := strings.Split(observerIDs, ",")
|
||||||
|
idSet := make(map[string]bool, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
ids[i] = strings.TrimSpace(id)
|
||||||
|
idSet[ids[i]] = true
|
||||||
|
}
|
||||||
if from != nil {
|
if from != nil {
|
||||||
return filterTxSlice(from, func(tx *StoreTx) bool {
|
return filterTxSlice(from, func(tx *StoreTx) bool {
|
||||||
for _, obs := range tx.Observations {
|
for _, obs := range tx.Observations {
|
||||||
if obs.ObserverID == observerID {
|
if idSet[obs.ObserverID] {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Use byObserver index
|
// Use byObserver index: union transmissions for all IDs
|
||||||
observations := s.byObserver[observerID]
|
seen := make(map[int]bool)
|
||||||
if len(observations) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seen := make(map[int]bool, len(observations))
|
|
||||||
var result []*StoreTx
|
var result []*StoreTx
|
||||||
for _, obs := range observations {
|
for _, id := range ids {
|
||||||
if seen[obs.TransmissionID] {
|
for _, obs := range s.byObserver[id] {
|
||||||
continue
|
if seen[obs.TransmissionID] {
|
||||||
}
|
continue
|
||||||
seen[obs.TransmissionID] = true
|
}
|
||||||
tx := s.byTxID[obs.TransmissionID]
|
seen[obs.TransmissionID] = true
|
||||||
if tx != nil {
|
tx := s.byTxID[obs.TransmissionID]
|
||||||
result = append(result, tx)
|
if tx != nil {
|
||||||
|
result = append(result, tx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -1939,6 +1983,7 @@ func (s *PacketStore) EvictStale() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove from byPayloadType
|
// Remove from byPayloadType
|
||||||
|
s.untrackAdvertPubkey(tx)
|
||||||
if tx.PayloadType != nil {
|
if tx.PayloadType != nil {
|
||||||
pt := *tx.PayloadType
|
pt := *tx.PayloadType
|
||||||
ptList := s.byPayloadType[pt]
|
ptList := s.byPayloadType[pt]
|
||||||
@@ -4473,10 +4518,16 @@ func (s *PacketStore) computeHashCollisions(region string) map[string]interface{
|
|||||||
// Compute collisions for each byte size (1, 2, 3)
|
// Compute collisions for each byte size (1, 2, 3)
|
||||||
collisionsBySize := make(map[string]interface{})
|
collisionsBySize := make(map[string]interface{})
|
||||||
for _, bytes := range []int{1, 2, 3} {
|
for _, bytes := range []int{1, 2, 3} {
|
||||||
// Filter nodes relevant to this byte size
|
// Filter nodes relevant to this byte size.
|
||||||
|
// - Exclude hash_size==0 nodes: no adverts seen, so actual hash
|
||||||
|
// size is unknown. Including them in every bucket inflates
|
||||||
|
// collision counts.
|
||||||
|
// - Exclude companions: they are mobile/temporary and don't form
|
||||||
|
// the mesh backbone, so collisions with them aren't meaningful.
|
||||||
|
// (Fixes #441)
|
||||||
var nodesForByte []collisionNode
|
var nodesForByte []collisionNode
|
||||||
for _, cn := range allCNodes {
|
for _, cn := range allCNodes {
|
||||||
if cn.HashSize == bytes || cn.HashSize == 0 {
|
if cn.HashSize == bytes && cn.Role == "repeater" {
|
||||||
nodesForByte = append(nodesForByte, cn)
|
nodesForByte = append(nodesForByte, cn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# Spec: Server-side hop resolution at ingest — `resolved_path`
|
||||||
|
|
||||||
|
**Status:** Final
|
||||||
|
**Issue:** [#555](https://github.com/Kpa-clawbot/CoreScope/issues/555)
|
||||||
|
**Related:** [#482](https://github.com/Kpa-clawbot/CoreScope/issues/482), [#528](https://github.com/Kpa-clawbot/CoreScope/issues/528)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Any place where 1, 2, or 3-byte prefixes must be resolved to actual full repeater public keys and friendly names should use affinity data first, geo data as fallback. Across frontend, backend, whatever. Efficiently — no 7-second waits, no recomputation, aggressive caching.
|
||||||
|
|
||||||
|
Currently, hop paths are stored as short uppercase hex prefixes in `path_json` (e.g. `["D6", "E3", "59"]`). Resolution to full pubkeys happens **client-side** via `HopResolver` (`public/hop-resolver.js`), which:
|
||||||
|
|
||||||
|
- Is slow — each page/component re-resolves independently
|
||||||
|
- Is inconsistent — different components may resolve the same prefix differently
|
||||||
|
- Cannot leverage the server's neighbor affinity graph, which has far richer context for disambiguation
|
||||||
|
- Causes redundant `/api/resolve-hops` calls from every client
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Resolve hop prefixes to full pubkeys **once at ingest time** on the server, using `resolveWithContext()` with 4-tier priority (affinity → geo → GPS → first match) and a **persisted neighbor graph**. Store the result as a new `resolved_path` column on observations alongside `path_json`.
|
||||||
|
|
||||||
|
## Design decisions (locked)
|
||||||
|
|
||||||
|
1. **`path_json` stays unchanged** — raw firmware prefixes, uppercase hex. Ground truth.
|
||||||
|
2. **`resolved_path` is a column on observations** — full 64-char lowercase hex pubkeys, `null` for unresolved.
|
||||||
|
3. **Resolved at ingest** using `resolveWithContext(hop, context, graph)` — 4-tier priority: affinity → geo → GPS → first match.
|
||||||
|
4. **`null` = unresolved** — ambiguous prefixes store `null`. Frontend falls back to prefix display.
|
||||||
|
5. **Both fields coexist** — not interchangeable. Different consumers use different fields.
|
||||||
|
|
||||||
|
## Persisted neighbor graph
|
||||||
|
|
||||||
|
### SQLite table: `neighbor_edges`
|
||||||
|
|
||||||
|
Thin and normalized. Stores ONLY the relationship. SNR, observer names, GPS, roles — all join from existing tables when needed. No duplication.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS neighbor_edges (
|
||||||
|
node_a TEXT NOT NULL,
|
||||||
|
node_b TEXT NOT NULL,
|
||||||
|
count INTEGER DEFAULT 1,
|
||||||
|
last_seen TEXT,
|
||||||
|
PRIMARY KEY (node_a, node_b)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge extraction rules (ADVERT vs non-ADVERT)
|
||||||
|
|
||||||
|
At ingest, for each packet:
|
||||||
|
|
||||||
|
- **ADVERT packets** (payload_type 4): originator pubkey is known from `decoded_json.pubKey`. Extract edge: `originator ↔ path[0]` (the first hop is a direct neighbor of the originator).
|
||||||
|
- **ALL packets**: observer pubkey is known. Extract edge: `observer ↔ path[last]` (the last hop is a direct neighbor of the observer).
|
||||||
|
- **Non-ADVERT packets**: originator is unknown (encrypted). ONLY extract `observer ↔ path[last]`.
|
||||||
|
- Each packet produces **1 or 2 edge upserts** depending on type.
|
||||||
|
|
||||||
|
Edge upsert uses canonical ordering (`node_a < node_b` lexicographically) to avoid duplicate edges:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||||||
|
VALUES (min(?, ?), max(?, ?), 1, ?)
|
||||||
|
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||||||
|
count = count + 1,
|
||||||
|
last_seen = excluded.last_seen;
|
||||||
|
```
|
||||||
|
|
||||||
|
### In-memory structure
|
||||||
|
|
||||||
|
```go
|
||||||
|
type NeighborGraph struct {
|
||||||
|
edges map[string][]NeighborEdge // pubkey → list of neighbor edges
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cold startup and backfill
|
||||||
|
|
||||||
|
On startup:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 1. Query all edges from SQLite
|
||||||
|
rows := db.Query("SELECT node_a, node_b, count, last_seen FROM neighbor_edges")
|
||||||
|
|
||||||
|
// 2. Build in-memory graph
|
||||||
|
graph := NewNeighborGraph()
|
||||||
|
for rows.Next() {
|
||||||
|
var a, b string
|
||||||
|
var count int
|
||||||
|
var lastSeen string
|
||||||
|
rows.Scan(&a, &b, &count, &lastSeen)
|
||||||
|
graph.UpsertEdge(a, b, count, lastSeen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Attach to PacketStore
|
||||||
|
store.graph = graph
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Load `neighbor_edges` from SQLite** → build in-memory graph (code above).
|
||||||
|
2. **If table empty (first run):** `BuildFromStore(packets)` — scan all existing packets, extract edges per the rules above, INSERT into `neighbor_edges`.
|
||||||
|
3. **Load observations from SQLite.**
|
||||||
|
4. **For observations without `resolved_path`:** resolve using the graph, UPDATE `resolved_path` in SQLite.
|
||||||
|
5. **Ready to serve.**
|
||||||
|
|
||||||
|
On subsequent runs, step 2 is skipped (table already populated). Step 4 only processes observations with NULL `resolved_path` (new or previously unresolved).
|
||||||
|
|
||||||
|
### Incremental update at ingest
|
||||||
|
|
||||||
|
Every edge upsert writes to **both** the in-memory graph and SQLite — they stay in sync. SQLite is the persistence layer, in-memory is the fast lookup layer.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Extract edge from packet
|
||||||
|
graph.UpsertEdge(nodeA, nodeB, 1, now)
|
||||||
|
|
||||||
|
// Also persist to SQLite
|
||||||
|
db.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||||||
|
VALUES (?, ?, 1, ?)
|
||||||
|
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||||||
|
count = count + 1, last_seen = ?`, a, b, now, now)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### Where does `resolved_path` live?
|
||||||
|
|
||||||
|
**On observations**, as a column:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE observations ADD COLUMN resolved_path TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: Each observer sees the packet from a different vantage point. The same 2-char prefix may resolve to different full pubkeys depending on which observer's neighborhood is considered. The observer's own pubkey provides critical context for `resolveWithContext` (tier 2: neighbor affinity). Storing on observations preserves this per-observer resolution.
|
||||||
|
|
||||||
|
`resolved_path` is written in the same INSERT that creates the observation — one write, no double-write problem.
|
||||||
|
|
||||||
|
### Field shape
|
||||||
|
|
||||||
|
```
|
||||||
|
resolved_path TEXT -- JSON array: ["aabb...64chars", null, "ccdd...64chars"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Same length as the `path_json` array
|
||||||
|
- Each element is either a 64-char lowercase hex pubkey string, or `null`
|
||||||
|
- Stored as a JSON text column (same approach as `path_json`)
|
||||||
|
- Uses `omitempty` — absent from JSON when not set
|
||||||
|
|
||||||
|
## Every path resolution uses the graph — no exceptions
|
||||||
|
|
||||||
|
All existing `pm.resolve()` call sites MUST be migrated to `resolveWithContext` with the persisted graph. No "we'll get to it later."
|
||||||
|
|
||||||
|
### Call sites to migrate (exhaustive)
|
||||||
|
|
||||||
|
Found via `grep -n "pm.resolve" cmd/server/store.go`:
|
||||||
|
|
||||||
|
| Line | Function | Current | After |
|
||||||
|
|------|----------|---------|-------|
|
||||||
|
| 1192 | `IngestNewFromDB()` | `pm.resolve(hop)` | `resolveWithContext(hop, ctx, graph)` — resolve at ingest, store as `resolved_path` |
|
||||||
|
| 1876 | `buildDistanceIndex()` | `pm.resolve(hop)` | Read `resolved_path` from observation — already resolved at ingest |
|
||||||
|
| 3537 | `computeAnalyticsTopology()` | `pm.resolve(hop)` | Read `resolved_path` from observation |
|
||||||
|
| 5528 | `computeAnalyticsSubpaths()` | `pm.resolve(hop)` | Read `resolved_path` from observation |
|
||||||
|
| 5665 | `GetSubpathDetail()` | `pm.resolve(hop)` | `resolveWithContext(hop, ctx, graph)` — ad-hoc resolution for user-provided hops |
|
||||||
|
| 5744 | `GetSubpathDetail()` | `pm.resolve(h)` | `resolveWithContext(h, ctx, graph)` — same function, second usage |
|
||||||
|
|
||||||
|
**After migration:** `pm.resolve()` (naive prefix-only lookup) is dead code. Remove it. All resolution goes through `resolveWithContext` which uses the persisted neighbor graph for affinity-based disambiguation.
|
||||||
|
|
||||||
|
## Ingest pipeline changes
|
||||||
|
|
||||||
|
### Where resolution happens
|
||||||
|
|
||||||
|
In `PacketStore.IngestNewFromDB()` in `cmd/server/store.go`. For new observations, resolution happens during the observation INSERT — same write. For backfill (cold startup), it's a separate UPDATE pass.
|
||||||
|
|
||||||
|
Note on ordering: edge upserts (step 5) happen **after** resolution (step 3-4). This means the very first packet for a new neighbor pair resolves without that edge in the graph yet. This is acceptable — the affinity tier will miss, but geo/GPS/first-match tiers still work. On the next packet, the edge exists and affinity kicks in.
|
||||||
|
|
||||||
|
Resolution flow per observation:
|
||||||
|
1. Parse `path_json` into hop prefixes
|
||||||
|
2. Build context pubkeys from the observation (observer pubkey, source/dest from decoded packet)
|
||||||
|
3. Call `resolveWithContext(hop, contextPubkeys, neighborGraph)` for each hop
|
||||||
|
4. Store result as `resolved_path` column on the observation (same INSERT)
|
||||||
|
5. Upsert neighbor edges into `neighbor_edges` table (incremental update)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
`resolveWithContext` does:
|
||||||
|
- Prefix map lookup (map access, O(1))
|
||||||
|
- Optional neighbor graph check (small map lookups)
|
||||||
|
- No DB queries, no network calls
|
||||||
|
|
||||||
|
Per-hop cost: ~1–5μs. A typical packet has 0–5 hops. At 100 packets/second ingest rate, this adds <0.5ms total overhead per second. **Negligible.**
|
||||||
|
|
||||||
|
## All consumers use `resolved_path`
|
||||||
|
|
||||||
|
| Consumer | Before | After |
|
||||||
|
|---|---|---|
|
||||||
|
| Packets detail path names | Client HopResolver (naive) | Read `resolved_path` |
|
||||||
|
| Map Show Route | Client HopResolver (naive) | Read `resolved_path` |
|
||||||
|
| Live map animated paths | Client HopResolver (naive) | Read `resolved_path` |
|
||||||
|
| Node detail paths | Client HopResolver (naive) | Read `resolved_path` |
|
||||||
|
| Analytics topology | Server `pm.resolve()` (naive) | Read `resolved_path` from observations |
|
||||||
|
| Analytics subpaths | Server `pm.resolve()` (naive) | Read `resolved_path` from observations |
|
||||||
|
| Analytics hop distances | Server `pm.resolve()` (naive) | Read `resolved_path` from observations |
|
||||||
|
| Subpath detail | Server `pm.resolve()` (naive) | `resolveWithContext` with graph |
|
||||||
|
| Show Neighbors | Server neighbors API | Already correct |
|
||||||
|
| `/api/resolve-hops` | Server `resolveWithContext` | Already correct |
|
||||||
|
| Hex breakdown display | `path_json` raw | Unchanged — shows raw bytes |
|
||||||
|
|
||||||
|
## WebSocket broadcast
|
||||||
|
|
||||||
|
Include `resolved_path` in broadcast messages. Resolution happens before broadcast assembly — negligible latency impact. The WS broadcast already includes `path_json`; `resolved_path` is added alongside it.
|
||||||
|
|
||||||
|
## API changes
|
||||||
|
|
||||||
|
### Endpoints that return `resolved_path`
|
||||||
|
|
||||||
|
All endpoints that currently return `path_json` also return `resolved_path`:
|
||||||
|
|
||||||
|
- `GET /api/packets` — transmission-level (use best observation's `resolved_path`)
|
||||||
|
- `GET /api/packets/:hash` — per-observation detail
|
||||||
|
- `GET /api/packets/:hash/observations` — each observation includes its own `resolved_path`
|
||||||
|
- WebSocket broadcast messages — per-observation
|
||||||
|
|
||||||
|
### `/api/resolve-hops`
|
||||||
|
|
||||||
|
**Kept.** Useful for ad-hoc resolution of arbitrary prefixes (debug tools, clients resolving prefixes not associated with a packet). Not deprecated.
|
||||||
|
|
||||||
|
## Pubkey case convention
|
||||||
|
|
||||||
|
- **DB/API:** lowercase
|
||||||
|
- **`path_json` display prefixes:** uppercase (raw firmware)
|
||||||
|
- **`resolved_path`:** lowercase full pubkeys
|
||||||
|
- **Comparison code:** normalizes to lowercase
|
||||||
|
|
||||||
|
## Backward compatibility
|
||||||
|
|
||||||
|
- Old observations without `resolved_path`: resolved during cold startup backfill (step 4). If still `null` after backfill, frontend falls back to client-side HopResolver.
|
||||||
|
- `resolved_path` field uses `omitempty` — absent from JSON when not set.
|
||||||
|
|
||||||
|
### Fallback pattern (frontend)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function getResolvedHops(packet) {
|
||||||
|
if (packet.resolved_path) return packet.resolved_path;
|
||||||
|
// Fall back to client-side resolution for old packets
|
||||||
|
return resolveHopsClientSide(packet.path_json);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation milestones
|
||||||
|
|
||||||
|
### M1: Persist graph to SQLite + load on startup + incremental updates at ingest
|
||||||
|
- Create `neighbor_edges` table in SQLite (schema above)
|
||||||
|
- On first run: `BuildFromStore(packets)` — scan all packets, extract edges per ADVERT/non-ADVERT rules, INSERT into table
|
||||||
|
- On subsequent runs: load from SQLite → build in-memory graph (instant startup)
|
||||||
|
- Upsert edges incrementally during packet ingest
|
||||||
|
- Graph lives on `PacketStore`, not `Server`
|
||||||
|
- Tests: graph persistence, load, incremental update, ADVERT vs non-ADVERT edge extraction
|
||||||
|
|
||||||
|
### M2: Add `resolved_path` column to observations + resolve at ingest
|
||||||
|
- `ALTER TABLE observations ADD COLUMN resolved_path TEXT`
|
||||||
|
- Add `ResolvedPath []*string` to `Observation` struct
|
||||||
|
- Resolve during `IngestNewFromDB` — same INSERT, one write
|
||||||
|
- Cold startup backfill: resolve observations with NULL `resolved_path`, UPDATE in SQLite
|
||||||
|
- Migrate ALL 6 `pm.resolve()` call sites to `resolveWithContext` or read from `resolved_path`
|
||||||
|
- Remove dead `pm.resolve()` code
|
||||||
|
- Tests: unit test resolution at ingest, verify stored values, verify all call sites use graph
|
||||||
|
|
||||||
|
### M3: Update all API responses to include `resolved_path`
|
||||||
|
- Include `resolved_path` in all packet/observation API responses
|
||||||
|
- Include in WebSocket broadcast messages
|
||||||
|
- Tests: verify API response shape, WS broadcast shape
|
||||||
|
|
||||||
|
### M4: Update frontend consumers to prefer `resolved_path`
|
||||||
|
- Update `packets.js`, `map.js`, `live.js`, `analytics.js`, `nodes.js`
|
||||||
|
- Add fallback to `path_json` + `HopResolver` for old packets
|
||||||
|
- `hop-resolver.js` becomes fallback only
|
||||||
|
- Tests: Playwright tests for path display
|
||||||
+79
-17
@@ -11,6 +11,7 @@ window.HopResolver = (function() {
|
|||||||
let nodesList = [];
|
let nodesList = [];
|
||||||
let observerIataMap = {}; // observer_id → iata
|
let observerIataMap = {}; // observer_id → iata
|
||||||
let iataCoords = {}; // iata → {lat, lon}
|
let iataCoords = {}; // iata → {lat, lon}
|
||||||
|
let affinityMap = {}; // pubkey → { neighborPubkey → score }
|
||||||
|
|
||||||
function dist(lat1, lon1, lat2, lon2) {
|
function dist(lat1, lon1, lat2, lon2) {
|
||||||
return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
|
return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
|
||||||
@@ -67,6 +68,34 @@ window.HopResolver = (function() {
|
|||||||
return null; // no GPS — can't geo-filter client-side
|
return null; // no GPS — can't geo-filter client-side
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the best candidate using affinity first, then geo-distance fallback.
|
||||||
|
* @param {Array} candidates - candidates with lat/lon/pubkey/name
|
||||||
|
* @param {string|null} adjacentPubkey - pubkey of the previously/next resolved hop
|
||||||
|
* @param {Object|null} anchor - {lat, lon} for geo fallback
|
||||||
|
* @param {number|null} fallbackLat - fallback anchor lat (e.g. observer)
|
||||||
|
* @param {number|null} fallbackLon - fallback anchor lon
|
||||||
|
* @returns {Object} best candidate
|
||||||
|
*/
|
||||||
|
function pickByAffinity(candidates, adjacentPubkey, anchor, fallbackLat, fallbackLon) {
|
||||||
|
// If we have affinity data and an adjacent hop, prefer neighbors
|
||||||
|
if (adjacentPubkey && Object.keys(affinityMap).length > 0) {
|
||||||
|
const withAffinity = candidates
|
||||||
|
.map(c => ({ ...c, affinity: getAffinity(adjacentPubkey, c.pubkey) }))
|
||||||
|
.filter(c => c.affinity > 0);
|
||||||
|
if (withAffinity.length > 0) {
|
||||||
|
withAffinity.sort((a, b) => b.affinity - a.affinity);
|
||||||
|
return withAffinity[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: geo-distance sort (existing behavior)
|
||||||
|
const effectiveAnchor = anchor || (fallbackLat != null ? { lat: fallbackLat, lon: fallbackLon } : null);
|
||||||
|
if (effectiveAnchor) {
|
||||||
|
candidates.sort((a, b) => dist(a.lat, a.lon, effectiveAnchor.lat, effectiveAnchor.lon) - dist(b.lat, b.lon, effectiveAnchor.lat, effectiveAnchor.lon));
|
||||||
|
}
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve an array of hex hop prefixes to node info.
|
* Resolve an array of hex hop prefixes to node info.
|
||||||
* Returns a map: { hop: {name, pubkey, lat, lon, ambiguous, unreliable} }
|
* Returns a map: { hop: {name, pubkey, lat, lon, ambiguous, unreliable} }
|
||||||
@@ -139,40 +168,50 @@ window.HopResolver = (function() {
|
|||||||
|
|
||||||
// Forward pass
|
// Forward pass
|
||||||
let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null;
|
let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null;
|
||||||
|
let lastResolvedPubkey = null;
|
||||||
for (let i = 0; i < hops.length; i++) {
|
for (let i = 0; i < hops.length; i++) {
|
||||||
const hop = hops[i];
|
const hop = hops[i];
|
||||||
if (hopPositions[hop]) { lastPos = hopPositions[hop]; continue; }
|
if (hopPositions[hop]) {
|
||||||
|
lastPos = hopPositions[hop];
|
||||||
|
lastResolvedPubkey = resolved[hop] ? resolved[hop].pubkey : null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const r = resolved[hop];
|
const r = resolved[hop];
|
||||||
if (!r || !r.ambiguous) continue;
|
if (!r || !r.ambiguous) continue;
|
||||||
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
|
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
|
||||||
if (!withLoc.length) continue;
|
if (!withLoc.length) continue;
|
||||||
let anchor = lastPos;
|
|
||||||
if (!anchor && i === hops.length - 1 && observerLat != null) {
|
// Affinity-aware: prefer candidates that are neighbors of the previous hop
|
||||||
anchor = { lat: observerLat, lon: observerLon };
|
const picked = pickByAffinity(withLoc, lastResolvedPubkey, lastPos, i === hops.length - 1 ? observerLat : null, i === hops.length - 1 ? observerLon : null);
|
||||||
}
|
r.name = picked.name;
|
||||||
if (anchor) {
|
r.pubkey = picked.pubkey;
|
||||||
withLoc.sort((a, b) => dist(a.lat, a.lon, anchor.lat, anchor.lon) - dist(b.lat, b.lon, anchor.lat, anchor.lon));
|
hopPositions[hop] = { lat: picked.lat, lon: picked.lon };
|
||||||
}
|
|
||||||
r.name = withLoc[0].name;
|
|
||||||
r.pubkey = withLoc[0].pubkey;
|
|
||||||
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
|
|
||||||
lastPos = hopPositions[hop];
|
lastPos = hopPositions[hop];
|
||||||
|
lastResolvedPubkey = picked.pubkey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backward pass
|
// Backward pass
|
||||||
let nextPos = (observerLat != null && observerLon != null) ? { lat: observerLat, lon: observerLon } : null;
|
let nextPos = (observerLat != null && observerLon != null) ? { lat: observerLat, lon: observerLon } : null;
|
||||||
|
let nextResolvedPubkey = null;
|
||||||
for (let i = hops.length - 1; i >= 0; i--) {
|
for (let i = hops.length - 1; i >= 0; i--) {
|
||||||
const hop = hops[i];
|
const hop = hops[i];
|
||||||
if (hopPositions[hop]) { nextPos = hopPositions[hop]; continue; }
|
if (hopPositions[hop]) {
|
||||||
|
nextPos = hopPositions[hop];
|
||||||
|
nextResolvedPubkey = resolved[hop] ? resolved[hop].pubkey : null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const r = resolved[hop];
|
const r = resolved[hop];
|
||||||
if (!r || !r.ambiguous) continue;
|
if (!r || !r.ambiguous) continue;
|
||||||
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
|
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
|
||||||
if (!withLoc.length || !nextPos) continue;
|
if (!withLoc.length || !nextPos) continue;
|
||||||
withLoc.sort((a, b) => dist(a.lat, a.lon, nextPos.lat, nextPos.lon) - dist(b.lat, b.lon, nextPos.lat, nextPos.lon));
|
|
||||||
r.name = withLoc[0].name;
|
// Affinity-aware: prefer candidates that are neighbors of the next hop
|
||||||
r.pubkey = withLoc[0].pubkey;
|
const picked = pickByAffinity(withLoc, nextResolvedPubkey, nextPos, null, null);
|
||||||
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
|
r.name = picked.name;
|
||||||
|
r.pubkey = picked.pubkey;
|
||||||
|
hopPositions[hop] = { lat: picked.lat, lon: picked.lon };
|
||||||
nextPos = hopPositions[hop];
|
nextPos = hopPositions[hop];
|
||||||
|
nextResolvedPubkey = picked.pubkey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanity check: drop hops impossibly far from neighbors
|
// Sanity check: drop hops impossibly far from neighbors
|
||||||
@@ -203,5 +242,28 @@ window.HopResolver = (function() {
|
|||||||
return nodesList.length > 0;
|
return nodesList.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { init: init, resolve: resolve, ready: ready, haversineKm: haversineKm };
|
/**
|
||||||
|
* Load neighbor-graph affinity data.
|
||||||
|
* @param {Object} graph - { edges: [{source, target, score, weight}, ...] }
|
||||||
|
*/
|
||||||
|
function setAffinity(graph) {
|
||||||
|
affinityMap = {};
|
||||||
|
if (!graph || !graph.edges) return;
|
||||||
|
for (const e of graph.edges) {
|
||||||
|
if (!affinityMap[e.source]) affinityMap[e.source] = {};
|
||||||
|
affinityMap[e.source][e.target] = e.score || e.weight || 1;
|
||||||
|
if (!affinityMap[e.target]) affinityMap[e.target] = {};
|
||||||
|
affinityMap[e.target][e.source] = e.score || e.weight || 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the affinity score between two pubkeys (0 if not neighbors).
|
||||||
|
*/
|
||||||
|
function getAffinity(pubkeyA, pubkeyB) {
|
||||||
|
if (!pubkeyA || !pubkeyB || !affinityMap[pubkeyA]) return 0;
|
||||||
|
return affinityMap[pubkeyA][pubkeyB] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init: init, resolve: resolve, ready: ready, haversineKm: haversineKm, setAffinity: setAffinity, getAffinity: getAffinity };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+89
-10
@@ -43,6 +43,7 @@
|
|||||||
timelineScope: 3600000, // 1h default ms
|
timelineScope: 3600000, // 1h default ms
|
||||||
timelineTimestamps: [], // historical timestamps from DB for sparkline
|
timelineTimestamps: [], // historical timestamps from DB for sparkline
|
||||||
timelineFetchedScope: 0, // last fetched scope to avoid redundant fetches
|
timelineFetchedScope: 0, // last fetched scope to avoid redundant fetches
|
||||||
|
replayGen: 0, // generation counter — incremented on each replay/rewind to discard stale async results
|
||||||
};
|
};
|
||||||
|
|
||||||
// ROLE_COLORS loaded from shared roles.js (includes 'unknown')
|
// ROLE_COLORS loaded from shared roles.js (includes 'unknown')
|
||||||
@@ -116,6 +117,7 @@
|
|||||||
|
|
||||||
function vcrResumeLive() {
|
function vcrResumeLive() {
|
||||||
stopReplay();
|
stopReplay();
|
||||||
|
VCR.replayGen++; // invalidate any in-flight async chunk processing
|
||||||
VCR.playhead = -1;
|
VCR.playhead = -1;
|
||||||
VCR.speed = 1;
|
VCR.speed = 1;
|
||||||
VCR.missedCount = 0;
|
VCR.missedCount = 0;
|
||||||
@@ -142,6 +144,8 @@
|
|||||||
function vcrReplayFromTs(targetTs) {
|
function vcrReplayFromTs(targetTs) {
|
||||||
const fetchFrom = new Date(targetTs).toISOString();
|
const fetchFrom = new Date(targetTs).toISOString();
|
||||||
stopReplay();
|
stopReplay();
|
||||||
|
VCR.replayGen++;
|
||||||
|
var gen = VCR.replayGen;
|
||||||
vcrSetMode('REPLAY');
|
vcrSetMode('REPLAY');
|
||||||
|
|
||||||
// Reload map nodes to match the replay time
|
// Reload map nodes to match the replay time
|
||||||
@@ -153,7 +157,10 @@
|
|||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const pkts = data.packets || [];
|
const pkts = data.packets || [];
|
||||||
const replayEntries = expandToBufferEntries(pkts);
|
return expandToBufferEntriesAsync(pkts);
|
||||||
|
})
|
||||||
|
.then(function(replayEntries) {
|
||||||
|
if (gen !== VCR.replayGen) return; // stale async result — user changed mode
|
||||||
if (replayEntries.length === 0) {
|
if (replayEntries.length === 0) {
|
||||||
vcrSetMode('PAUSED');
|
vcrSetMode('PAUSED');
|
||||||
return;
|
return;
|
||||||
@@ -202,6 +209,8 @@
|
|||||||
|
|
||||||
function vcrRewind(ms) {
|
function vcrRewind(ms) {
|
||||||
stopReplay();
|
stopReplay();
|
||||||
|
VCR.replayGen++;
|
||||||
|
var gen = VCR.replayGen;
|
||||||
// Fetch packets from DB for the time window
|
// Fetch packets from DB for the time window
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const from = new Date(now - ms).toISOString();
|
const from = new Date(now - ms).toISOString();
|
||||||
@@ -212,8 +221,11 @@
|
|||||||
// Prepend to buffer (avoid duplicates by ID)
|
// Prepend to buffer (avoid duplicates by ID)
|
||||||
const existingIds = new Set(VCR.buffer.map(b => b.pkt.id).filter(Boolean));
|
const existingIds = new Set(VCR.buffer.map(b => b.pkt.id).filter(Boolean));
|
||||||
const filtered = pkts.filter(p => !existingIds.has(p.id));
|
const filtered = pkts.filter(p => !existingIds.has(p.id));
|
||||||
const newEntries = expandToBufferEntries(filtered);
|
return expandToBufferEntriesAsync(filtered);
|
||||||
VCR.buffer = [...newEntries, ...VCR.buffer];
|
})
|
||||||
|
.then(function(newEntries) {
|
||||||
|
if (gen !== VCR.replayGen) return; // stale async result
|
||||||
|
VCR.buffer = [].concat(newEntries, VCR.buffer);
|
||||||
VCR.playhead = 0;
|
VCR.playhead = 0;
|
||||||
VCR.speed = 1;
|
VCR.speed = 1;
|
||||||
vcrSetMode('REPLAY');
|
vcrSetMode('REPLAY');
|
||||||
@@ -274,15 +286,18 @@
|
|||||||
// Get timestamp of last packet in buffer to fetch the next page
|
// Get timestamp of last packet in buffer to fetch the next page
|
||||||
const last = VCR.buffer[VCR.buffer.length - 1];
|
const last = VCR.buffer[VCR.buffer.length - 1];
|
||||||
if (!last) return Promise.resolve(false);
|
if (!last) return Promise.resolve(false);
|
||||||
|
var gen = VCR.replayGen;
|
||||||
const since = new Date(last.ts + 1).toISOString(); // +1ms to avoid dupe
|
const since = new Date(last.ts + 1).toISOString(); // +1ms to avoid dupe
|
||||||
return fetch(`/api/packets?limit=10000&grouped=false&expand=observations&since=${encodeURIComponent(since)}&order=asc`)
|
return fetch(`/api/packets?limit=10000&grouped=false&expand=observations&since=${encodeURIComponent(since)}&order=asc`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const pkts = data.packets || [];
|
const pkts = data.packets || [];
|
||||||
if (pkts.length === 0) return false;
|
if (pkts.length === 0) return false;
|
||||||
const newEntries = expandToBufferEntries(pkts);
|
return expandToBufferEntriesAsync(pkts).then(function(newEntries) {
|
||||||
VCR.buffer = VCR.buffer.concat(newEntries);
|
if (gen !== VCR.replayGen) return false; // stale
|
||||||
return true;
|
VCR.buffer = VCR.buffer.concat(newEntries);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
}
|
}
|
||||||
@@ -449,11 +464,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand a DB packet (with optional observations[]) into VCR buffer entries
|
// Expand a DB packet (with optional observations[]) into VCR buffer entries
|
||||||
|
/**
|
||||||
|
* Process packets into buffer entries in chunks to avoid blocking the main thread.
|
||||||
|
* Returns a Promise that resolves with the entries array.
|
||||||
|
* Each chunk processes CHUNK_SIZE packets, then yields to the event loop via setTimeout(0).
|
||||||
|
*/
|
||||||
|
var VCR_CHUNK_SIZE = 200;
|
||||||
|
function expandToBufferEntriesAsync(pkts) {
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
var entries = [];
|
||||||
|
var i = 0;
|
||||||
|
function processChunk() {
|
||||||
|
var end = Math.min(i + VCR_CHUNK_SIZE, pkts.length);
|
||||||
|
for (; i < end; i++) {
|
||||||
|
var p = pkts[i];
|
||||||
|
if (p.observations && p.observations.length > 0) {
|
||||||
|
for (var j = 0; j < p.observations.length; j++) {
|
||||||
|
var obs = p.observations[j];
|
||||||
|
entries.push({
|
||||||
|
ts: new Date(obs.timestamp || p.timestamp || p.created_at).getTime(),
|
||||||
|
pkt: dbPacketToLive(Object.assign({}, p, obs, { hash: p.hash, raw_hex: p.raw_hex, decoded_json: p.decoded_json }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entries.push({
|
||||||
|
ts: new Date(p.timestamp || p.created_at).getTime(),
|
||||||
|
pkt: dbPacketToLive(p)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i < pkts.length) {
|
||||||
|
setTimeout(processChunk, 0);
|
||||||
|
} else {
|
||||||
|
resolve(entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processChunk();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronous version kept for small datasets and backward compat (tests)
|
||||||
function expandToBufferEntries(pkts) {
|
function expandToBufferEntries(pkts) {
|
||||||
const entries = [];
|
var entries = [];
|
||||||
for (const p of pkts) {
|
for (var k = 0; k < pkts.length; k++) {
|
||||||
|
var p = pkts[k];
|
||||||
if (p.observations && p.observations.length > 0) {
|
if (p.observations && p.observations.length > 0) {
|
||||||
for (const obs of p.observations) {
|
for (var j = 0; j < p.observations.length; j++) {
|
||||||
|
var obs = p.observations[j];
|
||||||
entries.push({
|
entries.push({
|
||||||
ts: new Date(obs.timestamp || p.timestamp || p.created_at).getTime(),
|
ts: new Date(obs.timestamp || p.timestamp || p.created_at).getTime(),
|
||||||
pkt: dbPacketToLive(Object.assign({}, p, obs, { hash: p.hash, raw_hex: p.raw_hex, decoded_json: p.decoded_json }))
|
pkt: dbPacketToLive(Object.assign({}, p, obs, { hash: p.hash, raw_hex: p.raw_hex, decoded_json: p.decoded_json }))
|
||||||
@@ -1359,9 +1416,29 @@
|
|||||||
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
|
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
|
||||||
// Initialize shared HopResolver with loaded nodes
|
// Initialize shared HopResolver with loaded nodes
|
||||||
if (window.HopResolver) HopResolver.init(list);
|
if (window.HopResolver) HopResolver.init(list);
|
||||||
|
// Fetch affinity data for hop disambiguation
|
||||||
|
fetchAffinityData();
|
||||||
|
startAffinityRefresh();
|
||||||
} catch (e) { console.error('Failed to load nodes:', e); }
|
} catch (e) { console.error('Failed to load nodes:', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _affinityInterval = null;
|
||||||
|
|
||||||
|
async function fetchAffinityData() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/analytics/neighbor-graph');
|
||||||
|
const graph = await resp.json();
|
||||||
|
if (window.HopResolver && HopResolver.setAffinity) {
|
||||||
|
HopResolver.setAffinity(graph);
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('Failed to fetch affinity data:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAffinityRefresh() {
|
||||||
|
if (_affinityInterval) clearInterval(_affinityInterval);
|
||||||
|
_affinityInterval = setInterval(fetchAffinityData, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
function clearNodeMarkers() {
|
function clearNodeMarkers() {
|
||||||
if (nodesLayer) nodesLayer.clearLayers();
|
if (nodesLayer) nodesLayer.clearLayers();
|
||||||
if (animLayer) animLayer.clearLayers();
|
if (animLayer) animLayer.clearLayers();
|
||||||
@@ -1597,6 +1674,7 @@
|
|||||||
window._vcrFormatTime = vcrFormatTime;
|
window._vcrFormatTime = vcrFormatTime;
|
||||||
window._liveDbPacketToLive = dbPacketToLive;
|
window._liveDbPacketToLive = dbPacketToLive;
|
||||||
window._liveExpandToBufferEntries = expandToBufferEntries;
|
window._liveExpandToBufferEntries = expandToBufferEntries;
|
||||||
|
window._liveExpandToBufferEntriesAsync = expandToBufferEntriesAsync;
|
||||||
window._liveSEG_MAP = SEG_MAP;
|
window._liveSEG_MAP = SEG_MAP;
|
||||||
window._liveBufferPacket = bufferPacket;
|
window._liveBufferPacket = bufferPacket;
|
||||||
window._liveVCR = function() { return VCR; };
|
window._liveVCR = function() { return VCR; };
|
||||||
@@ -2552,6 +2630,7 @@
|
|||||||
if (_lcdClockInterval) { clearInterval(_lcdClockInterval); _lcdClockInterval = null; }
|
if (_lcdClockInterval) { clearInterval(_lcdClockInterval); _lcdClockInterval = null; }
|
||||||
if (_rateCounterInterval) { clearInterval(_rateCounterInterval); _rateCounterInterval = null; }
|
if (_rateCounterInterval) { clearInterval(_rateCounterInterval); _rateCounterInterval = null; }
|
||||||
if (_pruneInterval) { clearInterval(_pruneInterval); _pruneInterval = null; }
|
if (_pruneInterval) { clearInterval(_pruneInterval); _pruneInterval = null; }
|
||||||
|
if (_affinityInterval) { clearInterval(_affinityInterval); _affinityInterval = null; }
|
||||||
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
||||||
if (map) { map.remove(); map = null; }
|
if (map) { map.remove(); map = null; }
|
||||||
if (_onResize) {
|
if (_onResize) {
|
||||||
@@ -2584,7 +2663,7 @@
|
|||||||
packetCount = 0; activeAnims = 0;
|
packetCount = 0; activeAnims = 0;
|
||||||
nodeActivity = {}; pktTimestamps = [];
|
nodeActivity = {}; pktTimestamps = [];
|
||||||
feedDedup.clear();
|
feedDedup.clear();
|
||||||
VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1;
|
VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1; VCR.replayGen = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _themeRefreshHandler = null;
|
let _themeRefreshHandler = null;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
window.getParsedPath = function getParsedPath(p) {
|
window.getParsedPath = function getParsedPath(p) {
|
||||||
if (p._parsedPath !== undefined) return p._parsedPath;
|
if (p._parsedPath !== undefined) return p._parsedPath || [];
|
||||||
var raw = p.path_json;
|
var raw = p.path_json;
|
||||||
if (typeof raw !== 'string') {
|
if (typeof raw !== 'string') {
|
||||||
p._parsedPath = Array.isArray(raw) ? raw : [];
|
p._parsedPath = Array.isArray(raw) ? raw : [];
|
||||||
@@ -32,7 +32,7 @@ window.clearParsedCache = function clearParsedCache(p) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.getParsedDecoded = function getParsedDecoded(p) {
|
window.getParsedDecoded = function getParsedDecoded(p) {
|
||||||
if (p._parsedDecoded !== undefined) return p._parsedDecoded;
|
if (p._parsedDecoded !== undefined) return p._parsedDecoded || {};
|
||||||
var raw = p.decoded_json;
|
var raw = p.decoded_json;
|
||||||
if (typeof raw !== 'string') {
|
if (typeof raw !== 'string') {
|
||||||
p._parsedDecoded = (raw && typeof raw === 'object') ? raw : {};
|
p._parsedDecoded = (raw && typeof raw === 'object') ? raw : {};
|
||||||
|
|||||||
+38
-6
@@ -53,6 +53,7 @@
|
|||||||
let _displayPackets = []; // filtered packets for current view
|
let _displayPackets = []; // filtered packets for current view
|
||||||
let _displayGrouped = false; // whether _displayPackets is in grouped mode
|
let _displayGrouped = false; // whether _displayPackets is in grouped mode
|
||||||
let _rowCounts = []; // per-entry DOM row counts (1 for flat, 1+children for expanded groups)
|
let _rowCounts = []; // per-entry DOM row counts (1 for flat, 1+children for expanded groups)
|
||||||
|
let _rowCountsDirty = false; // set when _rowCounts may be stale (e.g. WS added children) (#410)
|
||||||
let _cumulativeOffsetsCache = null; // cached cumulative offsets, invalidated on _rowCounts change
|
let _cumulativeOffsetsCache = null; // cached cumulative offsets, invalidated on _rowCounts change
|
||||||
let _lastVisibleStart = -1; // last rendered start index (for dirty checking)
|
let _lastVisibleStart = -1; // last rendered start index (for dirty checking)
|
||||||
let _lastVisibleEnd = -1; // last rendered end index (for dirty checking)
|
let _lastVisibleEnd = -1; // last rendered end index (for dirty checking)
|
||||||
@@ -357,7 +358,7 @@
|
|||||||
if (pktTime && pktTime < cutoff) return false;
|
if (pktTime && pktTime < cutoff) return false;
|
||||||
}
|
}
|
||||||
if (filters.type) { const types = filters.type.split(',').map(Number); if (!types.includes(p.payload_type)) return false; }
|
if (filters.type) { const types = filters.type.split(',').map(Number); if (!types.includes(p.payload_type)) return false; }
|
||||||
if (filters.observer) { const obsSet = new Set(filters.observer.split(',')); if (!obsSet.has(p.observer_id)) return false; }
|
if (filters.observer) { const obsSet = new Set(filters.observer.split(',')); if (!obsSet.has(p.observer_id) && !(p._children && p._children.some(c => obsSet.has(String(c.observer_id))))) return false; }
|
||||||
if (filters.hash && p.hash !== filters.hash) return false;
|
if (filters.hash && p.hash !== filters.hash) return false;
|
||||||
if (RegionFilter.getRegionParam()) {
|
if (RegionFilter.getRegionParam()) {
|
||||||
const selectedRegions = RegionFilter.getRegionParam().split(',');
|
const selectedRegions = RegionFilter.getRegionParam().split(',');
|
||||||
@@ -396,6 +397,9 @@
|
|||||||
existing._children.unshift(p);
|
existing._children.unshift(p);
|
||||||
if (existing._children.length > 200) existing._children.length = 200;
|
if (existing._children.length > 200) existing._children.length = 200;
|
||||||
sortGroupChildren(existing);
|
sortGroupChildren(existing);
|
||||||
|
// Invalidate row counts — child count changed, so virtual scroll
|
||||||
|
// heights are stale until next renderTableRows() (#410)
|
||||||
|
_invalidateRowCounts();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// New group
|
// New group
|
||||||
@@ -442,6 +446,7 @@
|
|||||||
clearTimeout(_wsRenderTimer);
|
clearTimeout(_wsRenderTimer);
|
||||||
_displayPackets = [];
|
_displayPackets = [];
|
||||||
_rowCounts = [];
|
_rowCounts = [];
|
||||||
|
_rowCountsDirty = false;
|
||||||
_cumulativeOffsetsCache = null;
|
_cumulativeOffsetsCache = null;
|
||||||
_observerFilterSet = null;
|
_observerFilterSet = null;
|
||||||
_lastVisibleStart = -1;
|
_lastVisibleStart = -1;
|
||||||
@@ -488,6 +493,7 @@
|
|||||||
if (regionParam) params.set('region', regionParam);
|
if (regionParam) params.set('region', regionParam);
|
||||||
if (filters.hash) params.set('hash', filters.hash);
|
if (filters.hash) params.set('hash', filters.hash);
|
||||||
if (filters.node) params.set('node', filters.node);
|
if (filters.node) params.set('node', filters.node);
|
||||||
|
if (filters.observer) params.set('observer', filters.observer);
|
||||||
params.set('groupByHash', 'true'); // always fetch grouped
|
params.set('groupByHash', 'true'); // always fetch grouped
|
||||||
|
|
||||||
const data = await api('/packets?' + params.toString());
|
const data = await api('/packets?' + params.toString());
|
||||||
@@ -1099,8 +1105,8 @@
|
|||||||
|
|
||||||
// Build HTML for a single flat (ungrouped) packet row
|
// Build HTML for a single flat (ungrouped) packet row
|
||||||
function buildFlatRowHtml(p) {
|
function buildFlatRowHtml(p) {
|
||||||
const decoded = getParsedDecoded(p);
|
const decoded = getParsedDecoded(p) || {};
|
||||||
const pathHops = getParsedPath(p);
|
const pathHops = getParsedPath(p) || [];
|
||||||
const region = p.observer_id ? (observerMap.get(p.observer_id)?.iata || '') : '';
|
const region = p.observer_id ? (observerMap.get(p.observer_id)?.iata || '') : '';
|
||||||
const typeName = payloadTypeName(p.payload_type);
|
const typeName = payloadTypeName(p.payload_type);
|
||||||
const typeClass = payloadTypeColor(p.payload_type);
|
const typeClass = payloadTypeColor(p.payload_type);
|
||||||
@@ -1122,6 +1128,21 @@
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark _rowCounts as stale so renderVisibleRows() recomputes them lazily.
|
||||||
|
// Called when expanded group children change outside renderTableRows() (#410).
|
||||||
|
function _invalidateRowCounts() {
|
||||||
|
_rowCountsDirty = true;
|
||||||
|
_cumulativeOffsetsCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute _rowCounts from _displayPackets if they've been invalidated.
|
||||||
|
function _refreshRowCountsIfDirty() {
|
||||||
|
if (!_rowCountsDirty || !_displayPackets.length) return;
|
||||||
|
_rowCounts = _displayPackets.map(function(p) { return _getRowCount(p); });
|
||||||
|
_cumulativeOffsetsCache = null;
|
||||||
|
_rowCountsDirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Compute the number of DOM <tr> rows a single entry produces.
|
// Compute the number of DOM <tr> rows a single entry produces.
|
||||||
// Used by both row counting and renderVisibleRows to avoid divergence (#424).
|
// Used by both row counting and renderVisibleRows to avoid divergence (#424).
|
||||||
function _getRowCount(p) {
|
function _getRowCount(p) {
|
||||||
@@ -1160,6 +1181,9 @@
|
|||||||
const scrollContainer = document.getElementById('pktLeft');
|
const scrollContainer = document.getElementById('pktLeft');
|
||||||
if (!scrollContainer) return;
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
// Recompute row counts if they were invalidated (e.g. WS added children) (#410)
|
||||||
|
_refreshRowCountsIfDirty();
|
||||||
|
|
||||||
// Compute total DOM rows accounting for expanded groups
|
// Compute total DOM rows accounting for expanded groups
|
||||||
const offsets = _cumulativeRowOffsets();
|
const offsets = _cumulativeRowOffsets();
|
||||||
const totalDomRows = offsets[offsets.length - 1];
|
const totalDomRows = offsets[offsets.length - 1];
|
||||||
@@ -1291,7 +1315,11 @@
|
|||||||
}
|
}
|
||||||
if (filters.observer) {
|
if (filters.observer) {
|
||||||
const obsIds = new Set(filters.observer.split(','));
|
const obsIds = new Set(filters.observer.split(','));
|
||||||
displayPackets = displayPackets.filter(p => obsIds.has(p.observer_id));
|
displayPackets = displayPackets.filter(p => {
|
||||||
|
if (obsIds.has(p.observer_id)) return true;
|
||||||
|
if (p._children) return p._children.some(c => obsIds.has(String(c.observer_id)));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Packet Filter Language
|
// Packet Filter Language
|
||||||
@@ -1312,6 +1340,7 @@
|
|||||||
if (!displayPackets.length) {
|
if (!displayPackets.length) {
|
||||||
_displayPackets = [];
|
_displayPackets = [];
|
||||||
_rowCounts = [];
|
_rowCounts = [];
|
||||||
|
_rowCountsDirty = false;
|
||||||
_cumulativeOffsetsCache = null;
|
_cumulativeOffsetsCache = null;
|
||||||
_observerFilterSet = null;
|
_observerFilterSet = null;
|
||||||
_lastVisibleStart = -1;
|
_lastVisibleStart = -1;
|
||||||
@@ -1331,6 +1360,7 @@
|
|||||||
_displayGrouped = groupByHash;
|
_displayGrouped = groupByHash;
|
||||||
_observerFilterSet = filters.observer ? new Set(filters.observer.split(',')) : null;
|
_observerFilterSet = filters.observer ? new Set(filters.observer.split(',')) : null;
|
||||||
_rowCounts = displayPackets.map(p => _getRowCount(p));
|
_rowCounts = displayPackets.map(p => _getRowCount(p));
|
||||||
|
_rowCountsDirty = false;
|
||||||
_cumulativeOffsetsCache = null;
|
_cumulativeOffsetsCache = null;
|
||||||
|
|
||||||
attachVScrollListener();
|
attachVScrollListener();
|
||||||
@@ -1436,8 +1466,8 @@
|
|||||||
const pkt = data.packet;
|
const pkt = data.packet;
|
||||||
const breakdown = data.breakdown || {};
|
const breakdown = data.breakdown || {};
|
||||||
const ranges = breakdown.ranges || [];
|
const ranges = breakdown.ranges || [];
|
||||||
const decoded = getParsedDecoded(pkt);
|
const decoded = getParsedDecoded(pkt) || {};
|
||||||
const pathHops = getParsedPath(pkt);
|
const pathHops = getParsedPath(pkt) || [];
|
||||||
|
|
||||||
// Resolve sender GPS — from packet directly, or from known node in DB
|
// Resolve sender GPS — from packet directly, or from known node in DB
|
||||||
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
|
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
|
||||||
@@ -2039,6 +2069,8 @@
|
|||||||
renderPath,
|
renderPath,
|
||||||
_getRowCount,
|
_getRowCount,
|
||||||
_cumulativeRowOffsets,
|
_cumulativeRowOffsets,
|
||||||
|
_invalidateRowCounts,
|
||||||
|
_refreshRowCountsIfDirty,
|
||||||
buildGroupRowHtml,
|
buildGroupRowHtml,
|
||||||
buildFlatRowHtml,
|
buildFlatRowHtml,
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-1
@@ -947,7 +947,9 @@ button.ch-item.selected { background: var(--selected-bg); }
|
|||||||
.filter-bar { flex-direction: row; flex-wrap: wrap; gap: 4px; }
|
.filter-bar { flex-direction: row; flex-wrap: wrap; gap: 4px; }
|
||||||
.filter-toggle-btn { display: inline-flex !important; }
|
.filter-toggle-btn { display: inline-flex !important; }
|
||||||
.filter-bar > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: none; }
|
.filter-bar > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: none; }
|
||||||
.filter-bar.filters-expanded > * { display: inline-flex; }
|
/* Must match :not() specificity of the hide rule above, otherwise .filters-expanded loses
|
||||||
|
the specificity battle and filter children stay hidden (see issue #534). */
|
||||||
|
.filter-bar.filters-expanded > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: inline-flex; }
|
||||||
.filter-bar.filters-expanded > .col-toggle-wrap { display: inline-block; }
|
.filter-bar.filters-expanded > .col-toggle-wrap { display: inline-block; }
|
||||||
.filter-bar.filters-expanded input { width: 100%; }
|
.filter-bar.filters-expanded input { width: 100%; }
|
||||||
.filter-bar.filters-expanded select { width: 100%; }
|
.filter-bar.filters-expanded select { width: 100%; }
|
||||||
|
|||||||
@@ -1573,6 +1573,47 @@ async function run() {
|
|||||||
|
|
||||||
// ─── End affinity debug tests ─────────────────────────────────────────────
|
// ─── End affinity debug tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ─── Mobile filter dropdown tests (#534) ──────────────────────────────────
|
||||||
|
|
||||||
|
await test('Mobile: filter toggle expands filter bar on packets page (#534)', async () => {
|
||||||
|
// Use a mobile viewport
|
||||||
|
await page.setViewportSize({ width: 480, height: 800 });
|
||||||
|
await page.goto(`${BASE}/#/packets`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const filterBar = await page.$('.filter-bar');
|
||||||
|
assert(filterBar, 'Filter bar should exist on packets page');
|
||||||
|
|
||||||
|
// Before clicking toggle, filter inputs should be hidden
|
||||||
|
const toggleBtn = await page.$('.filter-toggle-btn');
|
||||||
|
assert(toggleBtn, 'Filter toggle button should exist on mobile');
|
||||||
|
|
||||||
|
await toggleBtn.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// After clicking, .filters-expanded should be on the filter bar
|
||||||
|
const expanded = await filterBar.evaluate(el => el.classList.contains('filters-expanded'));
|
||||||
|
assert(expanded, 'Filter bar should have filters-expanded class after toggle');
|
||||||
|
|
||||||
|
// Filter inputs should now be visible
|
||||||
|
const filterInput = await page.$('.filter-bar input');
|
||||||
|
if (filterInput) {
|
||||||
|
const display = await filterInput.evaluate(el => getComputedStyle(el).display);
|
||||||
|
assert(display !== 'none', `Filter input should be visible when expanded, got display: ${display}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterSelect = await page.$('.filter-bar select');
|
||||||
|
if (filterSelect) {
|
||||||
|
const display = await filterSelect.evaluate(el => getComputedStyle(el).display);
|
||||||
|
assert(display !== 'none', `Filter select should be visible when expanded, got display: ${display}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset viewport
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── End mobile filter tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
// Extract frontend coverage if instrumented server is running
|
// Extract frontend coverage if instrumented server is running
|
||||||
try {
|
try {
|
||||||
const coverage = await page.evaluate(() => window.__coverage__);
|
const coverage = await page.evaluate(() => window.__coverage__);
|
||||||
|
|||||||
@@ -2695,6 +2695,63 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
|
|||||||
'buildGroupRowHtml should use hoisted _observerFilterSet');
|
'buildGroupRowHtml should use hoisted _observerFilterSet');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('observer filter in grouped mode includes packet when child matches (#537)', () => {
|
||||||
|
// The display filter should keep a grouped packet whose primary observer_id
|
||||||
|
// does NOT match, but one of its _children does.
|
||||||
|
const obsIds = new Set(['OBS_B']);
|
||||||
|
const packets = [
|
||||||
|
{ observer_id: 'OBS_A', _children: [{ observer_id: 'OBS_A' }, { observer_id: 'OBS_B' }] },
|
||||||
|
{ observer_id: 'OBS_C', _children: [{ observer_id: 'OBS_C' }] },
|
||||||
|
];
|
||||||
|
const result = packets.filter(p => {
|
||||||
|
if (obsIds.has(p.observer_id)) return true;
|
||||||
|
if (p._children) return p._children.some(c => obsIds.has(String(c.observer_id)));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.length, 1, 'should keep packet with matching child observer');
|
||||||
|
assert.strictEqual(result[0].observer_id, 'OBS_A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observer filter in grouped mode hides packet with no matching observations (#537)', () => {
|
||||||
|
const obsIds = new Set(['OBS_X']);
|
||||||
|
const packets = [
|
||||||
|
{ observer_id: 'OBS_A', _children: [{ observer_id: 'OBS_A' }, { observer_id: 'OBS_B' }] },
|
||||||
|
];
|
||||||
|
const result = packets.filter(p => {
|
||||||
|
if (obsIds.has(p.observer_id)) return true;
|
||||||
|
if (p._children) return p._children.some(c => obsIds.has(String(c.observer_id)));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.length, 0, 'should hide packet with no matching observers');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WS observer filter checks children for grouped packets (#537)', () => {
|
||||||
|
const filters = { observer: 'OBS_B' };
|
||||||
|
const obsSet = new Set(filters.observer.split(','));
|
||||||
|
const p = { observer_id: 'OBS_A', _children: [{ observer_id: 'OBS_B' }] };
|
||||||
|
const passes = obsSet.has(p.observer_id) || (p._children && p._children.some(c => obsSet.has(String(c.observer_id))));
|
||||||
|
assert.ok(passes, 'WS filter should pass grouped packet when child matches');
|
||||||
|
|
||||||
|
const p2 = { observer_id: 'OBS_C', _children: [{ observer_id: 'OBS_D' }] };
|
||||||
|
const passes2 = obsSet.has(p2.observer_id) || (p2._children && p2._children.some(c => obsSet.has(String(c.observer_id))));
|
||||||
|
assert.ok(!passes2, 'WS filter should reject grouped packet with no matching observers');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('packets.js display filter checks _children for observer match (#537)', () => {
|
||||||
|
// Verify the actual source code has the children check
|
||||||
|
assert.ok(
|
||||||
|
packetsSource.includes('p._children) return p._children.some(c => obsIds.has(String(c.observer_id))'),
|
||||||
|
'display filter should check _children for observer match'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('packets.js WS filter checks _children for observer match (#537)', () => {
|
||||||
|
assert.ok(
|
||||||
|
packetsSource.includes('p._children && p._children.some(c => obsSet.has(String(c.observer_id)))'),
|
||||||
|
'WS filter should check _children for observer match'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('buildFlatRowHtml has null-safe decoded_json', () => {
|
test('buildFlatRowHtml has null-safe decoded_json', () => {
|
||||||
const flatBuilderMatch = packetsSource.match(/function buildFlatRowHtml[\s\S]*?(?=\n function )/);
|
const flatBuilderMatch = packetsSource.match(/function buildFlatRowHtml[\s\S]*?(?=\n function )/);
|
||||||
assert.ok(flatBuilderMatch, 'buildFlatRowHtml should exist');
|
assert.ok(flatBuilderMatch, 'buildFlatRowHtml should exist');
|
||||||
@@ -4126,7 +4183,17 @@ console.log('\n=== app.js: routeTypeName/payloadTypeName edge cases ===');
|
|||||||
assertJsonEqual(getParsedPath(p), []);
|
assertJsonEqual(getParsedPath(p), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getParsedPath: cached null _parsedPath returns empty array (#538)', () => {
|
||||||
|
const p = { path_json: '["a"]', _parsedPath: null };
|
||||||
|
assertJsonEqual(getParsedPath(p), []);
|
||||||
|
});
|
||||||
|
|
||||||
// --- getParsedDecoded ---
|
// --- getParsedDecoded ---
|
||||||
|
test('getParsedDecoded: cached null _parsedDecoded returns empty object (#538)', () => {
|
||||||
|
const p = { decoded_json: '{"x":1}', _parsedDecoded: null };
|
||||||
|
assertJsonEqual(getParsedDecoded(p), {});
|
||||||
|
});
|
||||||
|
|
||||||
test('getParsedDecoded: valid JSON object', () => {
|
test('getParsedDecoded: valid JSON object', () => {
|
||||||
const p = { decoded_json: '{"type":"GRP_TXT","text":"hello"}' };
|
const p = { decoded_json: '{"type":"GRP_TXT","text":"hello"}' };
|
||||||
const result = getParsedDecoded(p);
|
const result = getParsedDecoded(p);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for HopResolver affinity-aware hop resolution.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
// Load hop-resolver.js in a sandboxed context
|
||||||
|
const code = fs.readFileSync(__dirname + '/public/hop-resolver.js', 'utf8');
|
||||||
|
const sandbox = { window: {}, console, Math, Object, Array, Number, Date, Map, Set, parseInt, parseFloat, encodeURIComponent };
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(code, sandbox);
|
||||||
|
const HopResolver = sandbox.window.HopResolver;
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function assert(condition, msg) {
|
||||||
|
if (condition) { passed++; console.log(' ✓ ' + msg); }
|
||||||
|
else { failed++; console.error(' ✗ ' + msg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test nodes ──
|
||||||
|
// Two nodes share the same 1-byte prefix "ab"
|
||||||
|
const nodeA = { public_key: 'ab1111', name: 'NodeA', lat: 37.0, lon: -122.0 };
|
||||||
|
const nodeB = { public_key: 'ab2222', name: 'NodeB', lat: 38.0, lon: -123.0 };
|
||||||
|
const nodeC = { public_key: 'cd3333', name: 'NodeC', lat: 37.5, lon: -122.5 };
|
||||||
|
|
||||||
|
console.log('\n=== HopResolver Affinity Tests ===\n');
|
||||||
|
|
||||||
|
// Test 1: Affinity prefers neighbor candidate over geo-closest
|
||||||
|
console.log('Test 1: Affinity prefers neighbor over geo-closest');
|
||||||
|
HopResolver.init([nodeA, nodeB, nodeC]);
|
||||||
|
HopResolver.setAffinity({
|
||||||
|
edges: [
|
||||||
|
{ source: 'cd3333', target: 'ab2222', score: 0.8 }
|
||||||
|
// NodeC is a neighbor of NodeB but NOT NodeA
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve hop "ab" after NodeC was resolved — should pick NodeB (neighbor) not NodeA (geo-closer)
|
||||||
|
// Origin at NodeC's position so forward pass runs with NodeC as anchor
|
||||||
|
const result1 = HopResolver.resolve(['cd33', 'ab'], nodeC.lat, nodeC.lon, null, null, null);
|
||||||
|
assert(result1['ab'].name === 'NodeB', 'Should pick NodeB (affinity neighbor of NodeC) — got: ' + result1['ab'].name);
|
||||||
|
|
||||||
|
// Test 2: Without affinity, falls back to geo-closest
|
||||||
|
console.log('\nTest 2: Cold start (no affinity) falls back to geo-closest');
|
||||||
|
HopResolver.init([nodeA, nodeB, nodeC]);
|
||||||
|
HopResolver.setAffinity({}); // No edges
|
||||||
|
|
||||||
|
// With anchor at NodeC's position, NodeA is closer to NodeC than NodeB
|
||||||
|
const result2 = HopResolver.resolve(['cd33', 'ab'], nodeC.lat, nodeC.lon, null, null, null);
|
||||||
|
// NodeA (37, -122) is closer to NodeC (37.5, -122.5) than NodeB (38, -123)
|
||||||
|
assert(result2['ab'].name === 'NodeA', 'Should pick NodeA (geo-closest) — got: ' + result2['ab'].name);
|
||||||
|
|
||||||
|
// Test 3: setAffinity with null/undefined doesn't crash
|
||||||
|
console.log('\nTest 3: setAffinity with null/undefined is safe');
|
||||||
|
HopResolver.setAffinity(null);
|
||||||
|
HopResolver.setAffinity(undefined);
|
||||||
|
HopResolver.setAffinity({});
|
||||||
|
assert(true, 'No crash on null/undefined/empty affinity');
|
||||||
|
|
||||||
|
// Test 4: getAffinity returns correct scores
|
||||||
|
console.log('\nTest 4: getAffinity returns correct scores');
|
||||||
|
HopResolver.setAffinity({
|
||||||
|
edges: [
|
||||||
|
{ source: 'aaa', target: 'bbb', score: 0.95 },
|
||||||
|
{ source: 'ccc', target: 'ddd', weight: 5 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert(HopResolver.getAffinity('aaa', 'bbb') === 0.95, 'aaa→bbb = 0.95');
|
||||||
|
assert(HopResolver.getAffinity('bbb', 'aaa') === 0.95, 'bbb→aaa = 0.95 (bidirectional)');
|
||||||
|
assert(HopResolver.getAffinity('ccc', 'ddd') === 5, 'ccc→ddd = 5 (weight fallback)');
|
||||||
|
assert(HopResolver.getAffinity('aaa', 'zzz') === 0, 'unknown pair = 0');
|
||||||
|
assert(HopResolver.getAffinity(null, 'bbb') === 0, 'null pubkey = 0');
|
||||||
|
|
||||||
|
// Test 5: Affinity with multiple neighbors — highest score wins
|
||||||
|
console.log('\nTest 5: Highest affinity score wins among neighbors');
|
||||||
|
HopResolver.init([nodeA, nodeB, nodeC]);
|
||||||
|
HopResolver.setAffinity({
|
||||||
|
edges: [
|
||||||
|
{ source: 'cd3333', target: 'ab1111', score: 0.3 },
|
||||||
|
{ source: 'cd3333', target: 'ab2222', score: 0.9 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const result5 = HopResolver.resolve(['cd33', 'ab'], nodeC.lat, nodeC.lon, null, null, null);
|
||||||
|
assert(result5['ab'].name === 'NodeB', 'Should pick NodeB (highest affinity 0.9) — got: ' + result5['ab'].name);
|
||||||
|
|
||||||
|
// Test 6: Unambiguous hops are not affected by affinity
|
||||||
|
console.log('\nTest 6: Unambiguous hops unaffected by affinity');
|
||||||
|
const nodeD = { public_key: 'ee4444', name: 'NodeD', lat: 36.0, lon: -121.0 };
|
||||||
|
HopResolver.init([nodeA, nodeB, nodeC, nodeD]);
|
||||||
|
HopResolver.setAffinity({ edges: [] });
|
||||||
|
const result6 = HopResolver.resolve(['ee44'], null, null, null, null, null);
|
||||||
|
assert(result6['ee44'].name === 'NodeD', 'Unique prefix resolves directly — got: ' + result6['ee44'].name);
|
||||||
|
assert(!result6['ee44'].ambiguous, 'Should not be marked ambiguous');
|
||||||
|
|
||||||
|
console.log('\n' + (passed + failed) + ' tests, ' + passed + ' passed, ' + failed + ' failed\n');
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
@@ -272,6 +272,48 @@ console.log('\n=== live.js: expandToBufferEntries ===');
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== expandToBufferEntriesAsync (chunked, non-blocking) =====
|
||||||
|
console.log('\n=== live.js: expandToBufferEntriesAsync ===');
|
||||||
|
{
|
||||||
|
// Build a sandbox with packet-helpers loaded so expandToBufferEntries can call dbPacketToLive
|
||||||
|
const ctx = makeSandbox();
|
||||||
|
addLiveGlobals(ctx);
|
||||||
|
loadInCtx(ctx, 'public/roles.js');
|
||||||
|
loadInCtx(ctx, 'public/packet-helpers.js');
|
||||||
|
try { loadInCtx(ctx, 'public/live.js'); } catch (e) {
|
||||||
|
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||||
|
}
|
||||||
|
const expandSync = ctx.window._liveExpandToBufferEntries;
|
||||||
|
const expandAsync = ctx.window._liveExpandToBufferEntriesAsync;
|
||||||
|
assert.ok(expandAsync, '_liveExpandToBufferEntriesAsync must be exposed');
|
||||||
|
|
||||||
|
const pkts = [];
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
pkts.push({
|
||||||
|
id: i, hash: 'h' + i, timestamp: new Date(1700000000000 + i * 1000).toISOString(),
|
||||||
|
decoded_json: '{"type":"GRP_TXT"}', path_json: '[]',
|
||||||
|
observations: [
|
||||||
|
{ timestamp: new Date(1700000000000 + i * 1000 + 100).toISOString(), snr: 5, observer_name: 'O1' },
|
||||||
|
{ timestamp: new Date(1700000000000 + i * 1000 + 200).toISOString(), snr: 8, observer_name: 'O2' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sync expand handles 500 packets (1000 entries) correctly', () => {
|
||||||
|
const result = expandSync(pkts);
|
||||||
|
assert.strictEqual(result.length, 1000, '500 packets * 2 observations = 1000 entries');
|
||||||
|
assert.strictEqual(result[0].pkt.hash, 'h0');
|
||||||
|
assert.strictEqual(result[999].pkt.hash, 'h499');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VCR_CHUNK_SIZE is defined and async function yields via setTimeout', () => {
|
||||||
|
const src = fs.readFileSync(__dirname + '/public/live.js', 'utf8');
|
||||||
|
assert.ok(src.includes('VCR_CHUNK_SIZE'), 'VCR_CHUNK_SIZE constant must exist');
|
||||||
|
assert.ok(src.includes('expandToBufferEntriesAsync'), 'async version must exist');
|
||||||
|
assert.ok(src.includes('setTimeout(processChunk, 0)'), 'must yield via setTimeout between chunks');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ===== SEG_MAP (7-segment display) =====
|
// ===== SEG_MAP (7-segment display) =====
|
||||||
console.log('\n=== live.js: SEG_MAP ===');
|
console.log('\n=== live.js: SEG_MAP ===');
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -757,6 +757,33 @@ console.log('\n=== packets.js: page registration ===');
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('\n=== packets.js: _invalidateRowCounts / _refreshRowCountsIfDirty (#410) ===');
|
||||||
|
{
|
||||||
|
const ctx = loadPacketsSandbox();
|
||||||
|
const api = ctx._packetsTestAPI;
|
||||||
|
|
||||||
|
test('_invalidateRowCounts and _refreshRowCountsIfDirty are exported', () => {
|
||||||
|
assert(typeof api._invalidateRowCounts === 'function');
|
||||||
|
assert(typeof api._refreshRowCountsIfDirty === 'function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_invalidateRowCounts does not throw', () => {
|
||||||
|
api._invalidateRowCounts();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_refreshRowCountsIfDirty does not throw when no display packets', () => {
|
||||||
|
api._invalidateRowCounts();
|
||||||
|
api._refreshRowCountsIfDirty();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_cumulativeRowOffsets returns valid offsets after invalidation cycle', () => {
|
||||||
|
// Even with no display packets, should return valid array
|
||||||
|
const offsets = api._cumulativeRowOffsets();
|
||||||
|
assert(Array.isArray(offsets));
|
||||||
|
assert(offsets[0] === 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ===== SUMMARY =====
|
// ===== SUMMARY =====
|
||||||
console.log(`\n${'='.repeat(40)}`);
|
console.log(`\n${'='.repeat(40)}`);
|
||||||
console.log(`packets.js tests: ${passed} passed, ${failed} failed`);
|
console.log(`packets.js tests: ${passed} passed, ${failed} failed`);
|
||||||
|
|||||||
Reference in New Issue
Block a user