mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-03 13:11:55 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31745f9edc | |||
| a89b577ce5 | |||
| 6cd616bcef | |||
| 1d1cd46d3b | |||
| bc92b8b5c9 |
@@ -236,7 +236,7 @@ jobs:
|
||||
build:
|
||||
name: "🏗️ Build Docker Image"
|
||||
needs: [e2e-test]
|
||||
runs-on: [self-hosted, meshcore-vm]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
@@ -271,7 +271,7 @@ jobs:
|
||||
name: "🚀 Deploy Staging"
|
||||
if: github.event_name == 'push'
|
||||
needs: [build]
|
||||
runs-on: [self-hosted, meshcore-vm]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
@@ -362,12 +362,6 @@ One logical change per commit. Each commit is deployable. Each commit has its te
|
||||
- Tests: `test-{feature}.js` in repo root
|
||||
- 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
|
||||
- **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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,181 +0,0 @@
|
||||
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,7 +16,6 @@ func newTestStore(t *testing.T) *PacketStore {
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
invCooldown: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,164 +169,3 @@ 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%")
|
||||
}
|
||||
|
||||
@@ -55,8 +55,6 @@ type Config struct {
|
||||
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
||||
|
||||
Timestamps *TimestampConfig `json:"timestamps,omitempty"`
|
||||
|
||||
DebugAffinity bool `json:"debugAffinity,omitempty"`
|
||||
}
|
||||
|
||||
// PacketStoreConfig controls in-memory packet store limits.
|
||||
|
||||
@@ -3715,243 +3715,3 @@ func TestGetChannelMessagesAfterIngest(t *testing.T) {
|
||||
t.Errorf("newest message should be 'brand new message', got %q", lastMsg["text"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexByNodePreCheck(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
}
|
||||
|
||||
t.Run("indexes ADVERT with pubKey", func(t *testing.T) {
|
||||
tx := &StoreTx{Hash: "h1", DecodedJSON: `{"pubKey":"AABBCC","type":"ADVERT"}`}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode["AABBCC"]) != 1 {
|
||||
t.Errorf("expected 1 entry for pubKey AABBCC, got %d", len(store.byNode["AABBCC"]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("indexes destPubKey", func(t *testing.T) {
|
||||
tx := &StoreTx{Hash: "h2", DecodedJSON: `{"destPubKey":"DDEEFF","type":"MSG"}`}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode["DDEEFF"]) != 1 {
|
||||
t.Errorf("expected 1 entry for destPubKey DDEEFF, got %d", len(store.byNode["DDEEFF"]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("indexes srcPubKey", func(t *testing.T) {
|
||||
tx := &StoreTx{Hash: "h2b", DecodedJSON: `{"srcPubKey":"112233","type":"TXT_MSG"}`}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode["112233"]) != 1 {
|
||||
t.Errorf("expected 1 entry for srcPubKey 112233, got %d", len(store.byNode["112233"]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips channel message without pubKey", func(t *testing.T) {
|
||||
beforeLen := len(store.byNode)
|
||||
tx := &StoreTx{Hash: "h3", DecodedJSON: `{"type":"CHAN","channel":"#test","text":"hello"}`}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode) != beforeLen {
|
||||
t.Errorf("expected byNode unchanged for channel packet, got %d new entries", len(store.byNode)-beforeLen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips empty DecodedJSON", func(t *testing.T) {
|
||||
beforeLen := len(store.byNode)
|
||||
tx := &StoreTx{Hash: "h4", DecodedJSON: ""}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode) != beforeLen {
|
||||
t.Error("expected byNode unchanged for empty DecodedJSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deduplicates same hash", func(t *testing.T) {
|
||||
tx := &StoreTx{Hash: "h1", DecodedJSON: `{"pubKey":"AABBCC","type":"ADVERT"}`}
|
||||
store.indexByNode(tx) // second call for same hash
|
||||
if len(store.byNode["AABBCC"]) != 1 {
|
||||
t.Errorf("expected dedup to keep 1 entry, got %d", len(store.byNode["AABBCC"]))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkIndexByNode measures indexByNode performance with and without pubkey
|
||||
// fields to demonstrate the strings.Contains pre-check optimization.
|
||||
func BenchmarkIndexByNode(b *testing.B) {
|
||||
// Payload WITHOUT any pubkey fields — should be skipped via pre-check
|
||||
noPubkey := `{"type":1,"msgId":42,"sender":"node1","data":"hello world"}`
|
||||
// Payload WITH a pubkey field — requires JSON parse
|
||||
withPubkey := `{"type":1,"msgId":42,"pubKey":"AABB","sender":"node1","data":"hello world"}`
|
||||
|
||||
b.Run("no_pubkey_skip", func(b *testing.B) {
|
||||
store := &PacketStore{
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tx := &StoreTx{
|
||||
Hash: fmt.Sprintf("hash-%d", i),
|
||||
DecodedJSON: noPubkey,
|
||||
}
|
||||
store.indexByNode(tx)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("with_pubkey_parse", func(b *testing.B) {
|
||||
store := &PacketStore{
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tx := &StoreTx{
|
||||
Hash: fmt.Sprintf("hash-%d", i),
|
||||
DecodedJSON: withPubkey,
|
||||
}
|
||||
store.indexByNode(tx)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Distance index rebuild debounce (#557) ---
|
||||
|
||||
func TestDistanceRebuildDebounce(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
// After Load(), distLast is set to now — so distDirty should be false
|
||||
if store.distDirty {
|
||||
t.Fatal("distDirty should be false after Load()")
|
||||
}
|
||||
|
||||
// Insert a new observation with a different path to trigger distDirty
|
||||
maxObsID := db.GetMaxObservationID()
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 2, 5.0, -100, '["xx","yy","zz"]', ?)`, time.Now().Unix())
|
||||
|
||||
store.IngestNewObservations(maxObsID, 500)
|
||||
|
||||
// distDirty should be true (30s hasn't elapsed since Load)
|
||||
if !store.distDirty {
|
||||
t.Fatal("distDirty should be true after path change within 30s window")
|
||||
}
|
||||
|
||||
// Now simulate 30s having elapsed by backdating distLast
|
||||
store.distLast = time.Now().Add(-31 * time.Second)
|
||||
|
||||
// Insert another observation to trigger another ingest cycle
|
||||
maxObsID = db.GetMaxObservationID()
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 2, 7.0, -95, '["aa","bb","cc","dd"]', ?)`, time.Now().Unix())
|
||||
|
||||
store.IngestNewObservations(maxObsID, 500)
|
||||
|
||||
// After 30s elapsed, distDirty should be cleared (rebuild happened)
|
||||
if store.distDirty {
|
||||
t.Fatal("distDirty should be false after rebuild (30s elapsed)")
|
||||
}
|
||||
}
|
||||
|
||||
+9
-45
@@ -15,10 +15,9 @@ import (
|
||||
|
||||
// DB wraps a read-only connection to the MeshCore SQLite database.
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
path string // filesystem path to the database file
|
||||
isV3 bool // v3 schema: observer_idx in observations (vs observer_id in v2)
|
||||
hasResolvedPath bool // observations table has resolved_path column
|
||||
conn *sql.DB
|
||||
path string // filesystem path to the database file
|
||||
isV3 bool // v3 schema: observer_idx in observations (vs observer_id in v2)
|
||||
}
|
||||
|
||||
// OpenDB opens a read-only SQLite connection with WAL mode.
|
||||
@@ -62,13 +61,9 @@ func (db *DB) detectSchema() {
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil {
|
||||
if colName == "observer_idx" {
|
||||
db.isV3 = true
|
||||
}
|
||||
if colName == "resolved_path" {
|
||||
db.hasResolvedPath = true
|
||||
}
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "observer_idx" {
|
||||
db.isV3 = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -613,17 +608,12 @@ func (db *DB) buildTransmissionWhere(q PacketQuery) ([]string, []interface{}) {
|
||||
args = append(args, "%"+pk+"%")
|
||||
}
|
||||
if q.Observer != "" {
|
||||
ids := strings.Split(q.Observer, ",")
|
||||
placeholders := strings.Repeat("?,", len(ids))
|
||||
placeholders = placeholders[:len(placeholders)-1]
|
||||
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 IN ("+placeholders+"))")
|
||||
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 = ?)")
|
||||
} else {
|
||||
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))
|
||||
where = append(where, "EXISTS (SELECT 1 FROM observations oi WHERE oi.transmission_id = t.id AND oi.observer_id = ?)")
|
||||
}
|
||||
args = append(args, q.Observer)
|
||||
}
|
||||
if q.Region != "" {
|
||||
if db.isV3 {
|
||||
@@ -708,32 +698,6 @@ func (db *DB) GetNodes(limit, offset int, role, search, before, lastHeard, sortB
|
||||
}
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
codes := normalizeRegionCodes(region)
|
||||
if len(codes) > 0 {
|
||||
placeholders := make([]string, len(codes))
|
||||
regionArgs := make([]interface{}, len(codes))
|
||||
for i, c := range codes {
|
||||
placeholders[i] = "?"
|
||||
regionArgs[i] = c
|
||||
}
|
||||
joinCond := "obs.rowid = o.observer_idx"
|
||||
if !db.isV3 {
|
||||
joinCond = "obs.id = o.observer_id"
|
||||
}
|
||||
subq := fmt.Sprintf(`public_key IN (
|
||||
SELECT DISTINCT JSON_EXTRACT(t.decoded_json, '$.pubKey')
|
||||
FROM transmissions t
|
||||
JOIN observations o ON o.transmission_id = t.id
|
||||
JOIN observers obs ON %s
|
||||
WHERE t.payload_type = 4
|
||||
AND UPPER(TRIM(obs.iata)) IN (%s)
|
||||
)`, joinCond, strings.Join(placeholders, ","))
|
||||
where = append(where, subq)
|
||||
args = append(args, regionArgs...)
|
||||
}
|
||||
}
|
||||
|
||||
w := ""
|
||||
if len(where) > 0 {
|
||||
w = "WHERE " + strings.Join(where, " AND ")
|
||||
|
||||
@@ -1012,168 +1012,6 @@ func TestGetNodesFiltering(t *testing.T) {
|
||||
t.Errorf("expected 1 node with offset, got %d", len(nodes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("region filter SJC", func(t *testing.T) {
|
||||
nodes, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SJC")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("expected 1 node for SJC region, got %d", total)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0]["public_key"] != "aabbccdd11223344" {
|
||||
t.Errorf("expected TestRepeater, got %v", nodes[0]["public_key"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("region filter SFO", func(t *testing.T) {
|
||||
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SFO")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("expected 1 node for SFO region, got %d", total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("region filter multi", func(t *testing.T) {
|
||||
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SJC,SFO")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("expected 1 node for SJC,SFO region, got %d", total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("region filter unknown", func(t *testing.T) {
|
||||
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "AMS")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Errorf("expected 0 nodes for unknown region, got %d", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// setupTestDBV2 creates an in-memory SQLite database with the v2 schema
|
||||
// where observations use observer_id TEXT instead of observer_idx INTEGER.
|
||||
func setupTestDBV2(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.SetMaxOpenConns(1)
|
||||
|
||||
schema := `
|
||||
CREATE TABLE nodes (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
role TEXT,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
last_seen TEXT,
|
||||
first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0,
|
||||
battery_mv INTEGER,
|
||||
temperature_c REAL
|
||||
);
|
||||
|
||||
CREATE TABLE observers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
iata TEXT,
|
||||
last_seen TEXT,
|
||||
first_seen TEXT,
|
||||
packet_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
observer_id TEXT,
|
||||
observer_name TEXT,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
if _, err := conn.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &DB{conn: conn, isV3: false}
|
||||
}
|
||||
|
||||
func TestGetNodesRegionFilterV2(t *testing.T) {
|
||||
db := setupTestDBV2(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
recentEpoch := now.Add(-1 * time.Hour).Unix()
|
||||
|
||||
// Seed observer with IATA code
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obs-v2-1', 'V2 Observer', 'LAX', ?, '2026-01-01T00:00:00Z', 10)`, recent)
|
||||
|
||||
// Seed a node
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES ('v2pubkey11223344', 'V2Node', 'repeater', 34.0, -118.0, ?, '2026-01-01T00:00:00Z', 5)`, recent)
|
||||
|
||||
// Seed an ADVERT transmission for the node
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('AABB', 'v2hash0001', ?, 1, 4, '{"pubKey":"v2pubkey11223344","name":"V2Node","type":"ADVERT"}')`, recent)
|
||||
|
||||
// Seed v2-style observation: observer_id references observers.id directly
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_id, observer_name, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 'obs-v2-1', 'V2 Observer', 10.0, -90, '[]', ?)`, recentEpoch)
|
||||
|
||||
t.Run("v2 region filter match", func(t *testing.T) {
|
||||
nodes, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "LAX")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("expected 1 node for LAX region (v2 schema), got %d", total)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0]["public_key"] != "v2pubkey11223344" {
|
||||
t.Errorf("expected V2Node, got %v", nodes[0]["public_key"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("v2 region filter no match", func(t *testing.T) {
|
||||
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "JFK")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Errorf("expected 0 nodes for JFK region (v2 schema), got %d", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetChannelMessagesDedup(t *testing.T) {
|
||||
|
||||
+97
-68
@@ -163,6 +163,62 @@ func isTransportRoute(routeType int) bool {
|
||||
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
|
||||
}
|
||||
|
||||
// cleanHex removes whitespace from a hex string.
|
||||
func cleanHex(s string) string {
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.ReplaceAll(s, "\n", "")
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
return s
|
||||
}
|
||||
|
||||
// packetFrame holds parsed packet frame offsets used by both DecodePacket and BuildBreakdown.
|
||||
type packetFrame struct {
|
||||
buf []byte
|
||||
header Header
|
||||
hasTransport bool
|
||||
transportOffset int // start of transport codes (if present)
|
||||
pathOffset int // offset of path length byte
|
||||
pathByte byte
|
||||
hashSize int
|
||||
hashCount int
|
||||
pathDataOffset int // start of path hop data
|
||||
payloadOffset int // start of payload
|
||||
}
|
||||
|
||||
// parsePacketFrame parses the common packet frame structure (header, transport codes, path).
|
||||
// Returns nil if the packet is too short.
|
||||
func parsePacketFrame(buf []byte) *packetFrame {
|
||||
if len(buf) < 2 {
|
||||
return nil
|
||||
}
|
||||
f := &packetFrame{buf: buf}
|
||||
f.header = decodeHeader(buf[0])
|
||||
offset := 1
|
||||
|
||||
f.hasTransport = isTransportRoute(f.header.RouteType)
|
||||
if f.hasTransport {
|
||||
if len(buf) < offset+4 {
|
||||
return nil
|
||||
}
|
||||
f.transportOffset = offset
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if offset >= len(buf) {
|
||||
return nil
|
||||
}
|
||||
f.pathOffset = offset
|
||||
f.pathByte = buf[offset]
|
||||
offset++
|
||||
|
||||
f.hashSize = int(f.pathByte>>6) + 1
|
||||
f.hashCount = int(f.pathByte & 0x3F)
|
||||
f.pathDataOffset = offset
|
||||
offset += f.hashSize * f.hashCount
|
||||
f.payloadOffset = offset
|
||||
return f
|
||||
}
|
||||
|
||||
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
@@ -334,49 +390,34 @@ func decodePayload(payloadType int, buf []byte) Payload {
|
||||
|
||||
// DecodePacket decodes a hex-encoded MeshCore packet.
|
||||
func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
hexString = strings.ReplaceAll(hexString, " ", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\n", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\r", "")
|
||||
hexString = cleanHex(hexString)
|
||||
|
||||
buf, err := hex.DecodeString(hexString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hex: %w", err)
|
||||
}
|
||||
if len(buf) < 2 {
|
||||
return nil, fmt.Errorf("packet too short (need at least header + pathLength)")
|
||||
}
|
||||
|
||||
header := decodeHeader(buf[0])
|
||||
offset := 1
|
||||
f := parsePacketFrame(buf)
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf("packet too short")
|
||||
}
|
||||
|
||||
var tc *TransportCodes
|
||||
if isTransportRoute(header.RouteType) {
|
||||
if len(buf) < offset+4 {
|
||||
return nil, fmt.Errorf("packet too short for transport codes")
|
||||
}
|
||||
if f.hasTransport {
|
||||
tc = &TransportCodes{
|
||||
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||
Code1: strings.ToUpper(hex.EncodeToString(buf[f.transportOffset : f.transportOffset+2])),
|
||||
Code2: strings.ToUpper(hex.EncodeToString(buf[f.transportOffset+2 : f.transportOffset+4])),
|
||||
}
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if offset >= len(buf) {
|
||||
return nil, fmt.Errorf("packet too short (no path byte)")
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||
offset += bytesConsumed
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
payload := decodePayload(header.PayloadType, payloadBuf)
|
||||
path, _ := decodePath(f.pathByte, buf, f.pathDataOffset)
|
||||
payloadBuf := buf[f.payloadOffset:]
|
||||
payload := decodePayload(f.header.PayloadType, payloadBuf)
|
||||
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
// path field. The header path byte still encodes hashSize in bits 6-7, which
|
||||
// we use to split the payload path data into individual hop prefixes.
|
||||
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
|
||||
if f.header.PayloadType == PayloadTRACE && payload.PathData != "" {
|
||||
pathBytes, err := hex.DecodeString(payload.PathData)
|
||||
if err == nil && path.HashSize > 0 {
|
||||
hops := make([]string, 0, len(pathBytes)/path.HashSize)
|
||||
@@ -389,7 +430,7 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
}
|
||||
|
||||
return &DecodedPacket{
|
||||
Header: header,
|
||||
Header: f.header,
|
||||
TransportCodes: tc,
|
||||
Path: path,
|
||||
Payload: payload,
|
||||
@@ -411,66 +452,50 @@ type Breakdown struct {
|
||||
|
||||
// BuildBreakdown computes labeled byte ranges for each section of a MeshCore packet.
|
||||
// The returned ranges are consumed by createColoredHexDump() and buildHexLegend()
|
||||
// in the frontend (public/app.js).
|
||||
// in the frontend (public/packets.js).
|
||||
func BuildBreakdown(hexString string) *Breakdown {
|
||||
hexString = strings.ReplaceAll(hexString, " ", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\n", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\r", "")
|
||||
hexString = cleanHex(hexString)
|
||||
buf, err := hex.DecodeString(hexString)
|
||||
if err != nil || len(buf) < 2 {
|
||||
return &Breakdown{Ranges: []HexRange{}}
|
||||
}
|
||||
|
||||
f := parsePacketFrame(buf)
|
||||
if f == nil {
|
||||
return &Breakdown{Ranges: []HexRange{{Start: 0, End: 0, Label: "Header"}}}
|
||||
}
|
||||
|
||||
var ranges []HexRange
|
||||
offset := 0
|
||||
|
||||
// Byte 0: Header
|
||||
// Header byte
|
||||
ranges = append(ranges, HexRange{Start: 0, End: 0, Label: "Header"})
|
||||
offset = 1
|
||||
|
||||
header := decodeHeader(buf[0])
|
||||
|
||||
// Bytes 1-4: Transport Codes (TRANSPORT_FLOOD / TRANSPORT_DIRECT only)
|
||||
if isTransportRoute(header.RouteType) {
|
||||
if len(buf) < offset+4 {
|
||||
return &Breakdown{Ranges: ranges}
|
||||
}
|
||||
ranges = append(ranges, HexRange{Start: offset, End: offset + 3, Label: "Transport Codes"})
|
||||
offset += 4
|
||||
// Transport codes
|
||||
if f.hasTransport {
|
||||
ranges = append(ranges, HexRange{Start: f.transportOffset, End: f.transportOffset + 3, Label: "Transport Codes"})
|
||||
}
|
||||
|
||||
if offset >= len(buf) {
|
||||
return &Breakdown{Ranges: ranges}
|
||||
}
|
||||
|
||||
// Next byte: Path Length (bits 7-6 = hashSize-1, bits 5-0 = hashCount)
|
||||
ranges = append(ranges, HexRange{Start: offset, End: offset, Label: "Path Length"})
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
pathBytes := hashSize * hashCount
|
||||
// Path length byte
|
||||
ranges = append(ranges, HexRange{Start: f.pathOffset, End: f.pathOffset, Label: "Path Length"})
|
||||
|
||||
// Path hops
|
||||
if hashCount > 0 && offset+pathBytes <= len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: offset, End: offset + pathBytes - 1, Label: "Path"})
|
||||
pathBytes := f.hashSize * f.hashCount
|
||||
if f.hashCount > 0 && f.pathDataOffset+pathBytes <= len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: f.pathDataOffset, End: f.pathDataOffset + pathBytes - 1, Label: "Path"})
|
||||
}
|
||||
offset += pathBytes
|
||||
|
||||
if offset >= len(buf) {
|
||||
if f.payloadOffset >= len(buf) {
|
||||
return &Breakdown{Ranges: ranges}
|
||||
}
|
||||
|
||||
payloadStart := offset
|
||||
|
||||
// Payload — break ADVERT into named sub-fields; everything else is one Payload range
|
||||
if header.PayloadType == PayloadADVERT && len(buf)-payloadStart >= 100 {
|
||||
ranges = append(ranges, HexRange{Start: payloadStart, End: payloadStart + 31, Label: "PubKey"})
|
||||
ranges = append(ranges, HexRange{Start: payloadStart + 32, End: payloadStart + 35, Label: "Timestamp"})
|
||||
ranges = append(ranges, HexRange{Start: payloadStart + 36, End: payloadStart + 99, Label: "Signature"})
|
||||
if f.header.PayloadType == PayloadADVERT && len(buf)-f.payloadOffset >= 100 {
|
||||
ps := f.payloadOffset
|
||||
ranges = append(ranges, HexRange{Start: ps, End: ps + 31, Label: "PubKey"})
|
||||
ranges = append(ranges, HexRange{Start: ps + 32, End: ps + 35, Label: "Timestamp"})
|
||||
ranges = append(ranges, HexRange{Start: ps + 36, End: ps + 99, Label: "Signature"})
|
||||
|
||||
appStart := payloadStart + 100
|
||||
appStart := ps + 100
|
||||
if appStart < len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: appStart, End: appStart, Label: "Flags"})
|
||||
appFlags := buf[appStart]
|
||||
@@ -481,17 +506,21 @@ func BuildBreakdown(hexString string) *Breakdown {
|
||||
fOff += 8
|
||||
}
|
||||
if appFlags&0x20 != 0 && fOff+2 <= len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: fOff, End: fOff + 1, Label: "Feature1"})
|
||||
fOff += 2
|
||||
}
|
||||
if appFlags&0x40 != 0 && fOff+2 <= len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: fOff, End: fOff + 1, Label: "Feature2"})
|
||||
fOff += 2
|
||||
}
|
||||
if appFlags&0x80 != 0 && fOff < len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "Name"})
|
||||
} else if fOff < len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "AppData"})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ranges = append(ranges, HexRange{Start: payloadStart, End: len(buf) - 1, Label: "Payload"})
|
||||
ranges = append(ranges, HexRange{Start: f.payloadOffset, End: len(buf) - 1, Label: "Payload"})
|
||||
}
|
||||
|
||||
return &Breakdown{Ranges: ranges}
|
||||
|
||||
+183
-11
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -159,9 +160,9 @@ func TestBuildBreakdown_AdvertBasic(t *testing.T) {
|
||||
// PathByte 0x01: 1 hop, 1-byte hash
|
||||
// PathHop: AA
|
||||
// Payload: 100 bytes (PubKey32 + Timestamp4 + Signature64) + Flags=0x02 (repeater, no extras)
|
||||
pubkey := repeatHex("AB", 32)
|
||||
pubkey := strings.Repeat("AB", 32)
|
||||
ts := "00000000" // 4 bytes
|
||||
sig := repeatHex("CD", 64)
|
||||
sig := strings.Repeat("CD", 64)
|
||||
flags := "02"
|
||||
hex := "1101AA" + pubkey + ts + sig + flags
|
||||
b := BuildBreakdown(hex)
|
||||
@@ -176,9 +177,9 @@ func TestBuildBreakdown_AdvertBasic(t *testing.T) {
|
||||
|
||||
func TestBuildBreakdown_AdvertWithLocation(t *testing.T) {
|
||||
// flags=0x12: hasLocation bit set
|
||||
pubkey := repeatHex("00", 32)
|
||||
pubkey := strings.Repeat("00", 32)
|
||||
ts := "00000000"
|
||||
sig := repeatHex("00", 64)
|
||||
sig := strings.Repeat("00", 64)
|
||||
flags := "12" // 0x10 = hasLocation
|
||||
latBytes := "00000000"
|
||||
lonBytes := "00000000"
|
||||
@@ -190,9 +191,9 @@ func TestBuildBreakdown_AdvertWithLocation(t *testing.T) {
|
||||
|
||||
func TestBuildBreakdown_AdvertWithName(t *testing.T) {
|
||||
// flags=0x82: hasName bit set
|
||||
pubkey := repeatHex("00", 32)
|
||||
pubkey := strings.Repeat("00", 32)
|
||||
ts := "00000000"
|
||||
sig := repeatHex("00", 64)
|
||||
sig := strings.Repeat("00", 64)
|
||||
flags := "82" // 0x80 = hasName
|
||||
name := "4E6F6465" // "Node" in hex
|
||||
hex := "1101AA" + pubkey + ts + sig + flags + name
|
||||
@@ -235,10 +236,181 @@ func assertRange(t *testing.T, ranges []HexRange, label string, wantStart, wantE
|
||||
t.Errorf("range %q not found in %v", label, rangeLabels(ranges))
|
||||
}
|
||||
|
||||
func repeatHex(byteHex string, n int) string {
|
||||
s := ""
|
||||
for i := 0; i < n; i++ {
|
||||
s += byteHex
|
||||
|
||||
|
||||
// --- BuildBreakdown tests (PR #500 review feedback) ---
|
||||
|
||||
func TestBuildBreakdown_SimplePayload(t *testing.T) {
|
||||
// Header 0x11 = ADVERT + ZERO_HOP, path byte 0x00 = no hops
|
||||
// Payload < 100 bytes → single "Payload" range
|
||||
h := "1100" + strings.Repeat("AB", 10)
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
expect := []string{"Header", "Path Length", "Payload"}
|
||||
if len(labels) != len(expect) {
|
||||
t.Fatalf("expected %v, got %v", expect, labels)
|
||||
}
|
||||
for i, e := range expect {
|
||||
if labels[i] != e {
|
||||
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_TransportDirect(t *testing.T) {
|
||||
// TXT_MSG (0x01) + TRANSPORT_DIRECT (route 3) = 0x07
|
||||
h := "07" + "AABBCCDD" + "00" + strings.Repeat("EE", 5)
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
if len(labels) < 4 {
|
||||
t.Fatalf("expected ≥4 ranges, got %v", labels)
|
||||
}
|
||||
if labels[1] != "Transport Codes" {
|
||||
t.Errorf("expected Transport Codes, got %s", labels[1])
|
||||
}
|
||||
if bd.Ranges[1].Start != 1 || bd.Ranges[1].End != 4 {
|
||||
t.Errorf("transport range wrong: %d-%d", bd.Ranges[1].Start, bd.Ranges[1].End)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertAllFlags(t *testing.T) {
|
||||
// ADVERT + ZERO_HOP = 0x11, path 0x00
|
||||
// flags 0xF2 = location(0x10) + feat1(0x20) + feat2(0x40) + name(0x80) + type 2
|
||||
pubkey := strings.Repeat("AA", 32)
|
||||
ts := "01020304"
|
||||
sig := strings.Repeat("BB", 64)
|
||||
flags := "F2"
|
||||
loc := "0100000002000000"
|
||||
feat1 := "C1C2"
|
||||
feat2 := "D1D2"
|
||||
name := strings.Repeat("48", 5)
|
||||
|
||||
h := "11" + "00" + pubkey + ts + sig + flags + loc + feat1 + feat2 + name
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
|
||||
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
|
||||
"Flags", "Latitude", "Longitude", "Feature1", "Feature2", "Name"}
|
||||
if len(labels) != len(expect) {
|
||||
t.Fatalf("expected %v, got %v", expect, labels)
|
||||
}
|
||||
for i, e := range expect {
|
||||
if labels[i] != e {
|
||||
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
|
||||
}
|
||||
}
|
||||
// Verify no overlaps
|
||||
for i := 1; i < len(bd.Ranges); i++ {
|
||||
if bd.Ranges[i].Start <= bd.Ranges[i-1].End {
|
||||
t.Errorf("overlap: %s [%d-%d] and %s [%d-%d]",
|
||||
bd.Ranges[i-1].Label, bd.Ranges[i-1].Start, bd.Ranges[i-1].End,
|
||||
bd.Ranges[i].Label, bd.Ranges[i].Start, bd.Ranges[i].End)
|
||||
}
|
||||
}
|
||||
// Feature1 & Feature2 are each 2 bytes
|
||||
if sz := bd.Ranges[8].End - bd.Ranges[8].Start + 1; sz != 2 {
|
||||
t.Errorf("Feature1 should be 2 bytes, got %d", sz)
|
||||
}
|
||||
if sz := bd.Ranges[9].End - bd.Ranges[9].Start + 1; sz != 2 {
|
||||
t.Errorf("Feature2 should be 2 bytes, got %d", sz)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertFeat1Only(t *testing.T) {
|
||||
// flags 0xA1 = feat1(0x20) + name(0x80) + type 1, no location
|
||||
pubkey := strings.Repeat("AA", 32)
|
||||
ts := "01020304"
|
||||
sig := strings.Repeat("BB", 64)
|
||||
h := "11" + "00" + pubkey + ts + sig + "A1" + "F1F2" + strings.Repeat("4E", 4)
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
|
||||
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
|
||||
"Flags", "Feature1", "Name"}
|
||||
if len(labels) != len(expect) {
|
||||
t.Fatalf("expected %v, got %v", expect, labels)
|
||||
}
|
||||
for i, e := range expect {
|
||||
if labels[i] != e {
|
||||
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePacket_TransportDirect(t *testing.T) {
|
||||
h := "07" + "AABBCCDD" + "00" + strings.Repeat("EE", 5)
|
||||
pkt, err := DecodePacket(h)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pkt.Header.RouteType != RouteTransportDirect {
|
||||
t.Errorf("expected route %d, got %d", RouteTransportDirect, pkt.Header.RouteType)
|
||||
}
|
||||
if pkt.TransportCodes == nil {
|
||||
t.Fatal("expected transport codes")
|
||||
}
|
||||
if pkt.TransportCodes.Code1 != "AABB" {
|
||||
t.Errorf("Code1: expected AABB, got %s", pkt.TransportCodes.Code1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertShortPayload(t *testing.T) {
|
||||
// ADVERT with payload < 100 bytes should get generic "Payload" label, not sub-ranges
|
||||
h := "11" + "00" + strings.Repeat("FF", 50) // header + path + 50 bytes payload (< 100)
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
|
||||
// Should have Header, Path Length, Payload — no PubKey/Timestamp/Signature sub-ranges
|
||||
if labels[len(labels)-1] != "Payload" {
|
||||
t.Errorf("expected last range to be 'Payload', got %q", labels[len(labels)-1])
|
||||
}
|
||||
for _, l := range labels {
|
||||
if l == "PubKey" || l == "Timestamp" || l == "Signature" || l == "Flags" {
|
||||
t.Errorf("unexpected sub-range %q in short ADVERT", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertLocationAndName(t *testing.T) {
|
||||
// flags 0x91 = location(0x10) + name(0x80) + type 1
|
||||
pubkey := strings.Repeat("AA", 32)
|
||||
ts := "01020304"
|
||||
sig := strings.Repeat("BB", 64)
|
||||
lat := "11223344"
|
||||
lon := "55667788"
|
||||
name := strings.Repeat("4E", 6) // "NNNNNN"
|
||||
h := "11" + "00" + pubkey + ts + sig + "91" + lat + lon + name
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
|
||||
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
|
||||
"Flags", "Latitude", "Longitude", "Name"}
|
||||
if len(labels) != len(expect) {
|
||||
t.Fatalf("expected %v, got %v", expect, labels)
|
||||
}
|
||||
for i, e := range expect {
|
||||
if labels[i] != e {
|
||||
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertTrailingBytesNoName(t *testing.T) {
|
||||
// flags 0x11 = location(0x10) + type 1, NO name bit — trailing bytes should be "AppData"
|
||||
pubkey := strings.Repeat("AA", 32)
|
||||
ts := "01020304"
|
||||
sig := strings.Repeat("BB", 64)
|
||||
lat := "11223344"
|
||||
lon := "55667788"
|
||||
trailing := "DEADBEEF"
|
||||
h := "11" + "00" + pubkey + ts + sig + "11" + lat + lon + trailing
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
|
||||
lastLabel := labels[len(labels)-1]
|
||||
if lastLabel != "AppData" {
|
||||
t.Errorf("expected trailing bytes labeled 'AppData', got %q", lastLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// rangeLabels is defined earlier in this file
|
||||
|
||||
@@ -2,8 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -222,44 +220,6 @@ 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) {
|
||||
arr := []map[string]interface{}{
|
||||
{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5},
|
||||
|
||||
@@ -144,50 +144,6 @@ func main() {
|
||||
log.Fatalf("[store] failed to load: %v", err)
|
||||
}
|
||||
|
||||
// Initialize persisted neighbor graph
|
||||
dbPath = database.path
|
||||
if err := ensureNeighborEdgesTable(dbPath); err != nil {
|
||||
log.Printf("[neighbor] warning: could not create neighbor_edges table: %v", err)
|
||||
}
|
||||
// Add resolved_path column if missing.
|
||||
// NOTE on startup ordering (review item #10): ensureResolvedPathColumn runs AFTER
|
||||
// OpenDB/detectSchema, so db.hasResolvedPath will be false on first run with a
|
||||
// pre-existing DB. This means Load() won't SELECT resolved_path from SQLite.
|
||||
// That's OK: backfillResolvedPaths (below) computes and persists them in-memory
|
||||
// AND to SQLite. On next restart, detectSchema finds the column and Load() reads it.
|
||||
if err := ensureResolvedPathColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add resolved_path column: %v", err)
|
||||
} else {
|
||||
database.hasResolvedPath = true // detectSchema ran before column was added; fix the flag
|
||||
}
|
||||
|
||||
// Load or build neighbor graph
|
||||
if neighborEdgesTableExists(database.conn) {
|
||||
store.graph = loadNeighborEdgesFromDB(database.conn)
|
||||
log.Printf("[neighbor] loaded persisted neighbor graph")
|
||||
} else {
|
||||
log.Printf("[neighbor] no persisted edges found, building from store...")
|
||||
rw, rwErr := openRW(dbPath)
|
||||
if rwErr == nil {
|
||||
edgeCount := buildAndPersistEdges(store, rw)
|
||||
rw.Close()
|
||||
log.Printf("[neighbor] persisted %d edges", edgeCount)
|
||||
}
|
||||
store.graph = BuildFromStore(store)
|
||||
}
|
||||
|
||||
// Backfill resolved_path for observations that don't have it yet
|
||||
if backfilled := backfillResolvedPaths(store, dbPath); backfilled > 0 {
|
||||
log.Printf("[store] backfilled resolved_path for %d observations", backfilled)
|
||||
}
|
||||
|
||||
// Re-pick best observation now that resolved paths are populated
|
||||
store.mu.Lock()
|
||||
for _, tx := range store.packets {
|
||||
pickBestObservation(tx)
|
||||
}
|
||||
store.mu.Unlock()
|
||||
|
||||
// WebSocket hub
|
||||
hub := NewHub()
|
||||
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ─── Neighbor API response types ───────────────────────────────────────────────
|
||||
|
||||
type NeighborResponse struct {
|
||||
Node string `json:"node"`
|
||||
Neighbors []NeighborEntry `json:"neighbors"`
|
||||
TotalObservations int `json:"total_observations"`
|
||||
}
|
||||
|
||||
type NeighborEntry struct {
|
||||
Pubkey *string `json:"pubkey"`
|
||||
Prefix string `json:"prefix"`
|
||||
Name *string `json:"name"`
|
||||
Role *string `json:"role"`
|
||||
Count int `json:"count"`
|
||||
Score float64 `json:"score"`
|
||||
FirstSeen string `json:"first_seen"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
AvgSNR *float64 `json:"avg_snr"`
|
||||
Observers []string `json:"observers"`
|
||||
Ambiguous bool `json:"ambiguous"`
|
||||
Unresolved bool `json:"unresolved,omitempty"`
|
||||
Candidates []CandidateEntry `json:"candidates,omitempty"`
|
||||
}
|
||||
|
||||
type CandidateEntry struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type NeighborGraphResponse struct {
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Edges []GraphEdge `json:"edges"`
|
||||
Stats GraphStats `json:"stats"`
|
||||
}
|
||||
|
||||
type GraphNode struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
NeighborCount int `json:"neighbor_count"`
|
||||
}
|
||||
|
||||
type GraphEdge struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Weight int `json:"weight"`
|
||||
Score float64 `json:"score"`
|
||||
Bidirectional bool `json:"bidirectional"`
|
||||
AvgSNR *float64 `json:"avg_snr"`
|
||||
Ambiguous bool `json:"ambiguous"`
|
||||
}
|
||||
|
||||
type GraphStats struct {
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalEdges int `json:"total_edges"`
|
||||
AmbiguousEdges int `json:"ambiguous_edges"`
|
||||
AvgClusterSize float64 `json:"avg_cluster_size"`
|
||||
}
|
||||
|
||||
// ─── Graph accessor on Server ──────────────────────────────────────────────────
|
||||
|
||||
// getNeighborGraph returns the current neighbor graph, rebuilding if stale.
|
||||
func (s *Server) getNeighborGraph() *NeighborGraph {
|
||||
s.neighborMu.Lock()
|
||||
defer s.neighborMu.Unlock()
|
||||
|
||||
if s.neighborGraph == nil || s.neighborGraph.IsStale() {
|
||||
if s.store != nil {
|
||||
debugLog := s.cfg != nil && s.cfg.DebugAffinity
|
||||
s.neighborGraph = BuildFromStoreWithLog(s.store, debugLog)
|
||||
} else {
|
||||
s.neighborGraph = NewNeighborGraph()
|
||||
}
|
||||
}
|
||||
return s.neighborGraph
|
||||
}
|
||||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) handleNodeNeighbors(w http.ResponseWriter, r *http.Request) {
|
||||
pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
|
||||
|
||||
minCount := 1
|
||||
if v := r.URL.Query().Get("min_count"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
minCount = n
|
||||
}
|
||||
}
|
||||
minScore := 0.0
|
||||
if v := r.URL.Query().Get("min_score"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
minScore = f
|
||||
}
|
||||
}
|
||||
includeAmbiguous := true
|
||||
if v := r.URL.Query().Get("include_ambiguous"); v == "false" {
|
||||
includeAmbiguous = false
|
||||
}
|
||||
|
||||
graph := s.getNeighborGraph()
|
||||
edges := graph.Neighbors(pubkey)
|
||||
now := time.Now()
|
||||
|
||||
// Build node info lookup for names/roles.
|
||||
nodeMap := s.buildNodeInfoMap()
|
||||
|
||||
var entries []NeighborEntry
|
||||
totalObs := 0
|
||||
|
||||
for _, e := range edges {
|
||||
score := e.Score(now)
|
||||
if e.Count < minCount || score < minScore {
|
||||
continue
|
||||
}
|
||||
if e.Ambiguous && !includeAmbiguous {
|
||||
continue
|
||||
}
|
||||
|
||||
totalObs += e.Count
|
||||
|
||||
// Determine the "other" node (neighbor of the queried pubkey).
|
||||
neighborPK := e.NodeA
|
||||
if strings.EqualFold(neighborPK, pubkey) {
|
||||
neighborPK = e.NodeB
|
||||
}
|
||||
|
||||
entry := NeighborEntry{
|
||||
Prefix: e.Prefix,
|
||||
Count: e.Count,
|
||||
Score: score,
|
||||
FirstSeen: e.FirstSeen.UTC().Format(time.RFC3339),
|
||||
LastSeen: e.LastSeen.UTC().Format(time.RFC3339),
|
||||
Ambiguous: e.Ambiguous,
|
||||
Observers: observerList(e.Observers),
|
||||
}
|
||||
|
||||
if e.SNRCount > 0 {
|
||||
avg := e.AvgSNR()
|
||||
entry.AvgSNR = &avg
|
||||
}
|
||||
|
||||
if e.Ambiguous {
|
||||
if len(e.Candidates) == 0 {
|
||||
entry.Unresolved = true
|
||||
}
|
||||
for _, cpk := range e.Candidates {
|
||||
ce := CandidateEntry{Pubkey: cpk}
|
||||
if info, ok := nodeMap[strings.ToLower(cpk)]; ok {
|
||||
ce.Name = info.Name
|
||||
ce.Role = info.Role
|
||||
}
|
||||
entry.Candidates = append(entry.Candidates, ce)
|
||||
}
|
||||
} else if neighborPK != "" {
|
||||
entry.Pubkey = &neighborPK
|
||||
if info, ok := nodeMap[strings.ToLower(neighborPK)]; ok {
|
||||
entry.Name = &info.Name
|
||||
entry.Role = &info.Role
|
||||
}
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
// Sort by score descending.
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Score > entries[j].Score
|
||||
})
|
||||
|
||||
if entries == nil {
|
||||
entries = []NeighborEntry{}
|
||||
}
|
||||
|
||||
resp := NeighborResponse{
|
||||
Node: pubkey,
|
||||
Neighbors: entries,
|
||||
TotalObservations: totalObs,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleNeighborGraph(w http.ResponseWriter, r *http.Request) {
|
||||
minCount := 5
|
||||
if v := r.URL.Query().Get("min_count"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
minCount = n
|
||||
}
|
||||
}
|
||||
minScore := 0.1
|
||||
if v := r.URL.Query().Get("min_score"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
minScore = f
|
||||
}
|
||||
}
|
||||
region := r.URL.Query().Get("region")
|
||||
roleFilter := strings.ToLower(r.URL.Query().Get("role"))
|
||||
|
||||
graph := s.getNeighborGraph()
|
||||
allEdges := graph.AllEdges()
|
||||
now := time.Now()
|
||||
|
||||
// Resolve region observers if filtering.
|
||||
var regionObs map[string]bool
|
||||
if region != "" && s.store != nil {
|
||||
regionObs = s.store.resolveRegionObservers(region)
|
||||
}
|
||||
|
||||
nodeMap := s.buildNodeInfoMap()
|
||||
nodeSet := make(map[string]bool)
|
||||
var filteredEdges []GraphEdge
|
||||
ambiguousCount := 0
|
||||
|
||||
for _, e := range allEdges {
|
||||
score := e.Score(now)
|
||||
if e.Count < minCount || score < minScore {
|
||||
continue
|
||||
}
|
||||
|
||||
// Role filter: at least one endpoint must match the role.
|
||||
if roleFilter != "" && nodeMap != nil {
|
||||
aInfo, aOK := nodeMap[strings.ToLower(e.NodeA)]
|
||||
bInfo, bOK := nodeMap[strings.ToLower(e.NodeB)]
|
||||
aMatch := aOK && strings.EqualFold(aInfo.Role, roleFilter)
|
||||
bMatch := bOK && strings.EqualFold(bInfo.Role, roleFilter)
|
||||
if !aMatch && !bMatch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Region filter: at least one observer must be in the region.
|
||||
if regionObs != nil {
|
||||
match := false
|
||||
for obs := range e.Observers {
|
||||
if regionObs[obs] {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
ge := GraphEdge{
|
||||
Source: e.NodeA,
|
||||
Target: e.NodeB,
|
||||
Weight: e.Count,
|
||||
Score: score,
|
||||
Bidirectional: true,
|
||||
Ambiguous: e.Ambiguous,
|
||||
}
|
||||
if e.SNRCount > 0 {
|
||||
avg := e.AvgSNR()
|
||||
ge.AvgSNR = &avg
|
||||
}
|
||||
|
||||
if e.Ambiguous {
|
||||
ambiguousCount++
|
||||
// For ambiguous edges, use prefix as target.
|
||||
if e.NodeB == "" {
|
||||
ge.Target = "prefix:" + e.Prefix
|
||||
}
|
||||
}
|
||||
|
||||
filteredEdges = append(filteredEdges, ge)
|
||||
|
||||
// Track nodes.
|
||||
if e.NodeA != "" && !strings.HasPrefix(e.NodeA, "prefix:") {
|
||||
nodeSet[e.NodeA] = true
|
||||
}
|
||||
if e.NodeB != "" && !strings.HasPrefix(e.NodeB, "prefix:") {
|
||||
nodeSet[e.NodeB] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build node list.
|
||||
// Count neighbors per node from filtered edges.
|
||||
neighborCounts := make(map[string]int)
|
||||
for _, ge := range filteredEdges {
|
||||
neighborCounts[ge.Source]++
|
||||
neighborCounts[ge.Target]++
|
||||
}
|
||||
|
||||
var nodes []GraphNode
|
||||
for pk := range nodeSet {
|
||||
gn := GraphNode{Pubkey: pk, NeighborCount: neighborCounts[pk]}
|
||||
if info, ok := nodeMap[strings.ToLower(pk)]; ok {
|
||||
gn.Name = info.Name
|
||||
gn.Role = info.Role
|
||||
}
|
||||
nodes = append(nodes, gn)
|
||||
}
|
||||
|
||||
if filteredEdges == nil {
|
||||
filteredEdges = []GraphEdge{}
|
||||
}
|
||||
if nodes == nil {
|
||||
nodes = []GraphNode{}
|
||||
}
|
||||
|
||||
avgCluster := 0.0
|
||||
if len(nodes) > 0 {
|
||||
avgCluster = float64(len(filteredEdges)*2) / float64(len(nodes))
|
||||
}
|
||||
|
||||
resp := NeighborGraphResponse{
|
||||
Nodes: nodes,
|
||||
Edges: filteredEdges,
|
||||
Stats: GraphStats{
|
||||
TotalNodes: len(nodes),
|
||||
TotalEdges: len(filteredEdges),
|
||||
AmbiguousEdges: ambiguousCount,
|
||||
AvgClusterSize: avgCluster,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func observerList(m map[string]bool) []string {
|
||||
if len(m) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// buildNodeInfoMap returns a map of lowercase pubkey → nodeInfo for name/role lookups.
|
||||
func (s *Server) buildNodeInfoMap() map[string]nodeInfo {
|
||||
if s.store == nil {
|
||||
return nil
|
||||
}
|
||||
nodes, _ := s.store.getCachedNodesAndPM()
|
||||
m := make(map[string]nodeInfo, len(nodes))
|
||||
for _, n := range nodes {
|
||||
m[strings.ToLower(n.PublicKey)] = n
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// makeTestServer creates a Server with a pre-built neighbor graph for testing.
|
||||
func makeTestServer(graph *NeighborGraph) *Server {
|
||||
srv := &Server{
|
||||
perfStats: NewPerfStats(),
|
||||
}
|
||||
srv.neighborGraph = graph
|
||||
return srv
|
||||
}
|
||||
|
||||
// makeTestGraph creates a graph with given edges for testing.
|
||||
func makeTestGraph(edges ...*NeighborEdge) *NeighborGraph {
|
||||
g := NewNeighborGraph()
|
||||
g.mu.Lock()
|
||||
for _, e := range edges {
|
||||
key := makeEdgeKey(e.NodeA, e.NodeB)
|
||||
if e.NodeB == "" {
|
||||
key = makeEdgeKey(e.NodeA, "prefix:"+e.Prefix)
|
||||
}
|
||||
e.NodeA = key.A
|
||||
if e.NodeB != "" {
|
||||
e.NodeB = key.B
|
||||
}
|
||||
g.edges[key] = e
|
||||
g.byNode[key.A] = append(g.byNode[key.A], e)
|
||||
if key.B != "" && key.B != key.A {
|
||||
g.byNode[key.B] = append(g.byNode[key.B], e)
|
||||
}
|
||||
}
|
||||
g.builtAt = time.Now()
|
||||
g.mu.Unlock()
|
||||
return g
|
||||
}
|
||||
|
||||
func newEdge(a, b, prefix string, count int, lastSeen time.Time) *NeighborEdge {
|
||||
return &NeighborEdge{
|
||||
NodeA: a,
|
||||
NodeB: b,
|
||||
Prefix: prefix,
|
||||
Count: count,
|
||||
FirstSeen: lastSeen.Add(-24 * time.Hour),
|
||||
LastSeen: lastSeen,
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
SNRSum: -8.0,
|
||||
SNRCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func newAmbiguousEdge(knownPK, prefix string, candidates []string, count int, lastSeen time.Time) *NeighborEdge {
|
||||
return &NeighborEdge{
|
||||
NodeA: knownPK,
|
||||
NodeB: "",
|
||||
Prefix: prefix,
|
||||
Count: count,
|
||||
FirstSeen: lastSeen.Add(-24 * time.Hour),
|
||||
LastSeen: lastSeen,
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
Ambiguous: true,
|
||||
Candidates: candidates,
|
||||
}
|
||||
}
|
||||
|
||||
func serveRequest(srv *Server, method, path string) *httptest.ResponseRecorder {
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/api/nodes/{pubkey}/neighbors", srv.handleNodeNeighbors).Methods("GET")
|
||||
router.HandleFunc("/api/analytics/neighbor-graph", srv.handleNeighborGraph).Methods("GET")
|
||||
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// ─── Tests: /api/nodes/{pubkey}/neighbors ──────────────────────────────────────
|
||||
|
||||
func TestNeighborAPI_EmptyGraph(t *testing.T) {
|
||||
srv := makeTestServer(makeTestGraph())
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/deadbeef/neighbors")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad JSON: %v", err)
|
||||
}
|
||||
if resp.Node != "deadbeef" {
|
||||
t.Errorf("node = %q, want deadbeef", resp.Node)
|
||||
}
|
||||
if len(resp.Neighbors) != 0 {
|
||||
t.Errorf("expected 0 neighbors, got %d", len(resp.Neighbors))
|
||||
}
|
||||
if resp.TotalObservations != 0 {
|
||||
t.Errorf("expected 0 observations, got %d", resp.TotalObservations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_SingleNeighbor(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newEdge("aaaa", "bbbb", "bb", 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
n := resp.Neighbors[0]
|
||||
if n.Pubkey == nil || *n.Pubkey != "bbbb" {
|
||||
t.Errorf("expected pubkey bbbb, got %v", n.Pubkey)
|
||||
}
|
||||
if n.Count != 50 {
|
||||
t.Errorf("expected count 50, got %d", n.Count)
|
||||
}
|
||||
if n.Score <= 0 {
|
||||
t.Errorf("expected positive score, got %f", n.Score)
|
||||
}
|
||||
if n.Ambiguous {
|
||||
t.Error("expected not ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_MultipleNeighbors(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 10, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 2 {
|
||||
t.Fatalf("expected 2 neighbors, got %d", len(resp.Neighbors))
|
||||
}
|
||||
// Should be sorted by score descending.
|
||||
if resp.Neighbors[0].Score < resp.Neighbors[1].Score {
|
||||
t.Error("expected sorted by score descending")
|
||||
}
|
||||
if resp.TotalObservations != 110 {
|
||||
t.Errorf("expected 110 total observations, got %d", resp.TotalObservations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_AmbiguousCandidates(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 12, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
n := resp.Neighbors[0]
|
||||
if !n.Ambiguous {
|
||||
t.Error("expected ambiguous")
|
||||
}
|
||||
if n.Pubkey != nil {
|
||||
t.Errorf("expected nil pubkey for ambiguous, got %v", n.Pubkey)
|
||||
}
|
||||
if len(n.Candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(n.Candidates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_UnresolvedPrefix(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newAmbiguousEdge("aaaa", "ff", []string{}, 3, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
n := resp.Neighbors[0]
|
||||
if !n.Unresolved {
|
||||
t.Error("expected unresolved=true")
|
||||
}
|
||||
if len(n.Candidates) != 0 {
|
||||
t.Error("expected empty candidates for unresolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_MinCountFilter(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 2, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_count=10")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor after min_count filter, got %d", len(resp.Neighbors))
|
||||
}
|
||||
if *resp.Neighbors[0].Pubkey != "bbbb" {
|
||||
t.Error("expected bbbb to survive filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_MinScoreFilter(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // score ~1.0
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 1, now.Add(-30*24*time.Hour)) // very low score
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_score=0.5")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor after min_score filter, got %d", len(resp.Neighbors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_ExcludeAmbiguous(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 50, now)
|
||||
e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01"}, 10, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?include_ambiguous=false")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 non-ambiguous neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_UnknownNode(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newEdge("aaaa", "bbbb", "bb", 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/unknown1234/neighbors")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for unknown node, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
if len(resp.Neighbors) != 0 {
|
||||
t.Errorf("expected 0 neighbors for unknown node, got %d", len(resp.Neighbors))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests: /api/analytics/neighbor-graph ──────────────────────────────────────
|
||||
|
||||
func TestNeighborGraphAPI_EmptyGraph(t *testing.T) {
|
||||
srv := makeTestServer(makeTestGraph())
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Edges) != 0 {
|
||||
t.Errorf("expected 0 edges, got %d", len(resp.Edges))
|
||||
}
|
||||
if resp.Stats.TotalEdges != 0 {
|
||||
t.Errorf("expected 0 total edges, got %d", resp.Stats.TotalEdges)
|
||||
}
|
||||
if resp.Stats.TotalNodes != 0 {
|
||||
t.Errorf("expected 0 total nodes, got %d", resp.Stats.TotalNodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_WithEdges(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newEdge("bbbb", "cccc", "cc", 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(resp.Edges))
|
||||
}
|
||||
if resp.Stats.TotalNodes != 3 {
|
||||
t.Errorf("expected 3 nodes, got %d", resp.Stats.TotalNodes)
|
||||
}
|
||||
if resp.Stats.TotalEdges != 2 {
|
||||
t.Errorf("expected 2 total edges, got %d", resp.Stats.TotalEdges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_MinCountDefault(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // passes default min_count=5
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 2, now) // fails default min_count=5
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Edges) != 1 {
|
||||
t.Fatalf("expected 1 edge with default min_count=5, got %d", len(resp.Edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_AmbiguousEdgesCount(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if resp.Stats.AmbiguousEdges != 1 {
|
||||
t.Errorf("expected 1 ambiguous edge, got %d", resp.Stats.AmbiguousEdges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_RegionFilter(t *testing.T) {
|
||||
now := time.Now()
|
||||
// Edge with observer "obs-sjc" — would match region SJC if we had region resolution.
|
||||
// Without a store, region filtering returns nothing (no observers match).
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
srv := makeTestServer(makeTestGraph(e1))
|
||||
// No store → region filter has no observers → filters everything out.
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?region=SJC&min_count=1&min_score=0")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
// With no store, regionObs is nil so filter is skipped → all edges returned.
|
||||
// Actually: region="" when store is nil → regionObs stays nil → no filtering.
|
||||
// Wait, we set region=SJC and store is nil → resolveRegionObservers won't be called
|
||||
// because s.store is nil. So regionObs is nil → filter not applied.
|
||||
// Let's just check it doesn't crash.
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_ResponseShape(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("bad JSON: %v", err)
|
||||
}
|
||||
|
||||
// Verify top-level keys.
|
||||
for _, key := range []string{"nodes", "edges", "stats"} {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Errorf("missing key %q in response", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify stats keys.
|
||||
stats := raw["stats"].(map[string]interface{})
|
||||
for _, key := range []string{"total_nodes", "total_edges", "ambiguous_edges", "avg_cluster_size"} {
|
||||
if _, ok := stats[key]; !ok {
|
||||
t.Errorf("missing stats key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Debug API response types ──────────────────────────────────────────────────
|
||||
|
||||
type DebugAffinityResponse struct {
|
||||
Edges []DebugEdge `json:"edges"`
|
||||
Resolutions []DebugResolution `json:"resolutions"`
|
||||
Stats DebugStats `json:"stats"`
|
||||
}
|
||||
|
||||
type DebugEdge struct {
|
||||
NodeA string `json:"nodeA"`
|
||||
NodeAName string `json:"nodeAName,omitempty"`
|
||||
NodeB string `json:"nodeB"`
|
||||
NodeBName string `json:"nodeBName,omitempty"`
|
||||
Prefix string `json:"prefix"`
|
||||
Weight int `json:"weight"`
|
||||
ObservationCount int `json:"observationCount"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
FirstSeen string `json:"firstSeen"`
|
||||
Score float64 `json:"score"`
|
||||
Jaccard float64 `json:"jaccard,omitempty"`
|
||||
AvgSNR *float64 `json:"avgSnr,omitempty"`
|
||||
Observers []string `json:"observers"`
|
||||
Ambiguous bool `json:"ambiguous"`
|
||||
Unresolved bool `json:"unresolved,omitempty"`
|
||||
Resolved bool `json:"resolved,omitempty"`
|
||||
}
|
||||
|
||||
type DebugResolution struct {
|
||||
Prefix string `json:"prefix"`
|
||||
Chosen string `json:"chosen,omitempty"`
|
||||
ChosenName string `json:"chosenName,omitempty"`
|
||||
ChosenScore int `json:"chosenScore"`
|
||||
ChosenJaccard float64 `json:"chosenJaccard"`
|
||||
Confidence string `json:"confidence"`
|
||||
Candidates []DebugCandidate `json:"candidates"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
ThresholdApplied float64 `json:"thresholdApplied"`
|
||||
Method string `json:"method"`
|
||||
Tier string `json:"tier"`
|
||||
KnownNode string `json:"knownNode"`
|
||||
KnownNodeName string `json:"knownNodeName,omitempty"`
|
||||
}
|
||||
|
||||
type DebugCandidate struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Score int `json:"score"`
|
||||
Jaccard float64 `json:"jaccard"`
|
||||
}
|
||||
|
||||
type DebugStats struct {
|
||||
TotalEdges int `json:"totalEdges"`
|
||||
TotalNodes int `json:"totalNodes"`
|
||||
ResolvedCount int `json:"resolvedCount"`
|
||||
AmbiguousCount int `json:"ambiguousCount"`
|
||||
UnresolvedCount int `json:"unresolvedCount"`
|
||||
AvgConfidence float64 `json:"avgConfidence"`
|
||||
ColdStartCoverage float64 `json:"coldStartCoverage"`
|
||||
CacheAge string `json:"cacheAge"`
|
||||
LastRebuild string `json:"lastRebuild"`
|
||||
}
|
||||
|
||||
// ─── Debug API Handler ─────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) handleDebugAffinity(w http.ResponseWriter, r *http.Request) {
|
||||
prefixFilter := strings.ToLower(r.URL.Query().Get("prefix"))
|
||||
nodeFilter := strings.ToLower(r.URL.Query().Get("node"))
|
||||
|
||||
graph := s.getNeighborGraph()
|
||||
now := time.Now()
|
||||
nodeMap := s.buildNodeInfoMap()
|
||||
|
||||
allEdges := graph.AllEdges()
|
||||
|
||||
// Build edges response
|
||||
var debugEdges []DebugEdge
|
||||
nodeSet := make(map[string]bool)
|
||||
resolvedCount := 0
|
||||
ambiguousCount := 0
|
||||
unresolvedCount := 0
|
||||
var scoreSum float64
|
||||
var scoreCount int
|
||||
|
||||
for _, e := range allEdges {
|
||||
// Apply filters
|
||||
if prefixFilter != "" && !strings.EqualFold(e.Prefix, prefixFilter) {
|
||||
continue
|
||||
}
|
||||
if nodeFilter != "" {
|
||||
if !strings.EqualFold(e.NodeA, nodeFilter) && !strings.EqualFold(e.NodeB, nodeFilter) {
|
||||
// Also check if any candidate matches
|
||||
found := false
|
||||
for _, c := range e.Candidates {
|
||||
if strings.EqualFold(c, nodeFilter) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
score := e.Score(now)
|
||||
de := DebugEdge{
|
||||
NodeA: e.NodeA,
|
||||
NodeB: e.NodeB,
|
||||
Prefix: e.Prefix,
|
||||
Weight: e.Count,
|
||||
ObservationCount: e.Count,
|
||||
LastSeen: e.LastSeen.UTC().Format(time.RFC3339),
|
||||
FirstSeen: e.FirstSeen.UTC().Format(time.RFC3339),
|
||||
Score: math.Round(score*1000) / 1000,
|
||||
Observers: observerList(e.Observers),
|
||||
Ambiguous: e.Ambiguous,
|
||||
Resolved: e.Resolved,
|
||||
}
|
||||
|
||||
if e.SNRCount > 0 {
|
||||
avg := e.AvgSNR()
|
||||
de.AvgSNR = &avg
|
||||
}
|
||||
|
||||
// Add names
|
||||
if nodeMap != nil {
|
||||
if info, ok := nodeMap[strings.ToLower(e.NodeA)]; ok {
|
||||
de.NodeAName = info.Name
|
||||
}
|
||||
if info, ok := nodeMap[strings.ToLower(e.NodeB)]; ok {
|
||||
de.NodeBName = info.Name
|
||||
}
|
||||
}
|
||||
|
||||
if e.Ambiguous {
|
||||
if len(e.Candidates) == 0 {
|
||||
de.Unresolved = true
|
||||
unresolvedCount++
|
||||
} else {
|
||||
ambiguousCount++
|
||||
}
|
||||
} else {
|
||||
resolvedCount++
|
||||
scoreSum += score
|
||||
scoreCount++
|
||||
}
|
||||
|
||||
debugEdges = append(debugEdges, de)
|
||||
|
||||
if e.NodeA != "" && !strings.HasPrefix(e.NodeA, "prefix:") {
|
||||
nodeSet[e.NodeA] = true
|
||||
}
|
||||
if e.NodeB != "" && !strings.HasPrefix(e.NodeB, "prefix:") {
|
||||
nodeSet[e.NodeB] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build resolutions from the graph's disambiguation history
|
||||
resolutions := s.buildResolutions(graph, nodeMap, prefixFilter, nodeFilter)
|
||||
|
||||
// Cold-start coverage: % of 1-byte prefixes with ≥3 observations
|
||||
coldStart := s.computeColdStartCoverage(allEdges)
|
||||
|
||||
avgConf := 0.0
|
||||
if scoreCount > 0 {
|
||||
avgConf = math.Round(scoreSum/float64(scoreCount)*1000) / 1000
|
||||
}
|
||||
|
||||
if debugEdges == nil {
|
||||
debugEdges = []DebugEdge{}
|
||||
}
|
||||
if resolutions == nil {
|
||||
resolutions = []DebugResolution{}
|
||||
}
|
||||
|
||||
// Sort edges by weight descending
|
||||
sort.Slice(debugEdges, func(i, j int) bool {
|
||||
return debugEdges[i].Weight > debugEdges[j].Weight
|
||||
})
|
||||
|
||||
graph.mu.RLock()
|
||||
builtAt := graph.builtAt
|
||||
graph.mu.RUnlock()
|
||||
|
||||
cacheAge := ""
|
||||
lastRebuild := ""
|
||||
if !builtAt.IsZero() {
|
||||
cacheAge = fmt.Sprintf("%.1fs", time.Since(builtAt).Seconds())
|
||||
lastRebuild = builtAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
resp := DebugAffinityResponse{
|
||||
Edges: debugEdges,
|
||||
Resolutions: resolutions,
|
||||
Stats: DebugStats{
|
||||
TotalEdges: len(debugEdges),
|
||||
TotalNodes: len(nodeSet),
|
||||
ResolvedCount: resolvedCount,
|
||||
AmbiguousCount: ambiguousCount,
|
||||
UnresolvedCount: unresolvedCount,
|
||||
AvgConfidence: avgConf,
|
||||
ColdStartCoverage: coldStart,
|
||||
CacheAge: cacheAge,
|
||||
LastRebuild: lastRebuild,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// buildResolutions generates per-prefix resolution decision logs.
|
||||
// It uses resolveWithContext (M4) to show the actual 4-tier fallback path
|
||||
// (affinity → geo → GPS → first_match) for each prefix resolution.
|
||||
func (s *Server) buildResolutions(graph *NeighborGraph, nodeMap map[string]nodeInfo, prefixFilter, nodeFilter string) []DebugResolution {
|
||||
graph.mu.RLock()
|
||||
defer graph.mu.RUnlock()
|
||||
|
||||
// Get the prefix map for resolveWithContext tier computation.
|
||||
var pm *prefixMap
|
||||
if s.store != nil {
|
||||
_, pm = s.store.getCachedNodesAndPM()
|
||||
}
|
||||
|
||||
// Build resolved neighbor sets for Jaccard computation
|
||||
resolvedNeighbors := make(map[string]map[string]bool)
|
||||
for _, e := range graph.edges {
|
||||
if e.Ambiguous || e.NodeB == "" {
|
||||
continue
|
||||
}
|
||||
if resolvedNeighbors[e.NodeA] == nil {
|
||||
resolvedNeighbors[e.NodeA] = make(map[string]bool)
|
||||
}
|
||||
if resolvedNeighbors[e.NodeB] == nil {
|
||||
resolvedNeighbors[e.NodeB] = make(map[string]bool)
|
||||
}
|
||||
resolvedNeighbors[e.NodeA][e.NodeB] = true
|
||||
resolvedNeighbors[e.NodeB][e.NodeA] = true
|
||||
}
|
||||
|
||||
var resolutions []DebugResolution
|
||||
|
||||
for _, e := range graph.edges {
|
||||
// Show resolution info for both resolved (auto-resolved) and ambiguous edges
|
||||
if !e.Resolved && !e.Ambiguous {
|
||||
continue
|
||||
}
|
||||
if len(e.Candidates) < 2 && !e.Resolved {
|
||||
continue
|
||||
}
|
||||
|
||||
if prefixFilter != "" && !strings.EqualFold(e.Prefix, prefixFilter) {
|
||||
continue
|
||||
}
|
||||
|
||||
knownNode := e.NodeA
|
||||
if strings.HasPrefix(e.NodeA, "prefix:") {
|
||||
knownNode = e.NodeB
|
||||
}
|
||||
|
||||
if nodeFilter != "" && !strings.EqualFold(knownNode, nodeFilter) {
|
||||
// Check if the resolved node matches
|
||||
if e.Resolved && !strings.EqualFold(e.NodeB, nodeFilter) && !strings.EqualFold(e.NodeA, nodeFilter) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
knownNeighbors := resolvedNeighbors[knownNode]
|
||||
|
||||
var candidates []DebugCandidate
|
||||
candList := e.Candidates
|
||||
// For resolved edges, add the resolved node as a candidate too
|
||||
if e.Resolved {
|
||||
resolvedPK := e.NodeB
|
||||
if strings.EqualFold(e.NodeB, knownNode) {
|
||||
resolvedPK = e.NodeA
|
||||
}
|
||||
// Include resolved + original candidates
|
||||
found := false
|
||||
for _, c := range candList {
|
||||
if strings.EqualFold(c, resolvedPK) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
candList = append([]string{resolvedPK}, candList...)
|
||||
}
|
||||
}
|
||||
|
||||
for _, cpk := range candList {
|
||||
candNeighbors := resolvedNeighbors[cpk]
|
||||
j := jaccardSimilarity(knownNeighbors, candNeighbors)
|
||||
dc := DebugCandidate{
|
||||
Pubkey: cpk,
|
||||
Score: e.Count,
|
||||
Jaccard: math.Round(j*1000) / 1000,
|
||||
}
|
||||
if nodeMap != nil {
|
||||
if info, ok := nodeMap[strings.ToLower(cpk)]; ok {
|
||||
dc.Name = info.Name
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, dc)
|
||||
}
|
||||
|
||||
// Sort candidates by Jaccard descending
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].Jaccard > candidates[j].Jaccard
|
||||
})
|
||||
|
||||
dr := DebugResolution{
|
||||
Prefix: e.Prefix,
|
||||
ThresholdApplied: affinityConfidenceRatio,
|
||||
KnownNode: knownNode,
|
||||
}
|
||||
|
||||
if nodeMap != nil {
|
||||
if info, ok := nodeMap[strings.ToLower(knownNode)]; ok {
|
||||
dr.KnownNodeName = info.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Use resolveWithContext to determine the actual 4-tier fallback path.
|
||||
tier := ""
|
||||
if pm != nil {
|
||||
contextPubkeys := []string{knownNode}
|
||||
_, tierUsed, _ := pm.resolveWithContext(e.Prefix, contextPubkeys, graph)
|
||||
tier = tierUsed
|
||||
}
|
||||
|
||||
if e.Resolved && len(candidates) > 0 {
|
||||
dr.Chosen = candidates[0].Pubkey
|
||||
dr.ChosenName = candidates[0].Name
|
||||
dr.ChosenScore = candidates[0].Score
|
||||
dr.ChosenJaccard = candidates[0].Jaccard
|
||||
dr.Confidence = "HIGH"
|
||||
dr.Method = "auto-resolved"
|
||||
dr.Tier = tier
|
||||
if len(candidates) > 1 && candidates[1].Jaccard > 0 {
|
||||
dr.Ratio = math.Round(candidates[0].Jaccard/candidates[1].Jaccard*10) / 10
|
||||
} else if candidates[0].Jaccard > 0 {
|
||||
dr.Ratio = 999.0 // effectively infinite — JSON doesn't support Infinity
|
||||
}
|
||||
} else {
|
||||
dr.Confidence = "AMBIGUOUS"
|
||||
dr.Method = "ambiguous"
|
||||
dr.Tier = tier
|
||||
if len(candidates) >= 2 {
|
||||
dr.ChosenScore = candidates[0].Score
|
||||
dr.ChosenJaccard = candidates[0].Jaccard
|
||||
if candidates[1].Jaccard > 0 {
|
||||
dr.Ratio = math.Round(candidates[0].Jaccard/candidates[1].Jaccard*10) / 10
|
||||
}
|
||||
}
|
||||
}
|
||||
dr.Candidates = candidates
|
||||
|
||||
resolutions = append(resolutions, dr)
|
||||
}
|
||||
|
||||
return resolutions
|
||||
}
|
||||
|
||||
// computeColdStartCoverage returns the % of active 1-byte hex prefixes with ≥3 observations.
|
||||
func (s *Server) computeColdStartCoverage(edges []*NeighborEdge) float64 {
|
||||
// Track which 1-byte prefixes have sufficient observations
|
||||
prefixObs := make(map[string]int) // 1-byte prefix → total observations
|
||||
for _, e := range edges {
|
||||
if len(e.Prefix) == 2 { // 1-byte = 2 hex chars
|
||||
prefixObs[strings.ToLower(e.Prefix)] += e.Count
|
||||
}
|
||||
}
|
||||
|
||||
if len(prefixObs) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
covered := 0
|
||||
for _, count := range prefixObs {
|
||||
if count >= affinityMinObservations {
|
||||
covered++
|
||||
}
|
||||
}
|
||||
|
||||
return math.Round(float64(covered)/float64(len(prefixObs))*1000) / 10
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDebugAffinityEndpoint(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
edge1 := newEdge("aaaa1111", "bbbb2222", "bb", 50, now)
|
||||
edge2 := newEdge("aaaa1111", "", "cc", 10, now)
|
||||
edge2.Ambiguous = true
|
||||
edge2.Candidates = []string{"cccc3333", "cccc4444"}
|
||||
|
||||
graph := makeTestGraph(edge1, edge2)
|
||||
srv := makeTestServer(graph)
|
||||
srv.cfg = &Config{APIKey: "test-key", DebugAffinity: true}
|
||||
|
||||
r, _ := http.NewRequest("GET", "/api/debug/affinity", nil)
|
||||
r.Header.Set("X-API-Key", "test-key")
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleDebugAffinity(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp DebugAffinityResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Edges) != 2 {
|
||||
t.Errorf("expected 2 edges, got %d", len(resp.Edges))
|
||||
}
|
||||
|
||||
// Check stats shape
|
||||
if resp.Stats.TotalEdges != 2 {
|
||||
t.Errorf("expected 2 total edges in stats, got %d", resp.Stats.TotalEdges)
|
||||
}
|
||||
if resp.Stats.LastRebuild == "" {
|
||||
t.Error("expected lastRebuild to be set")
|
||||
}
|
||||
if resp.Stats.CacheAge == "" {
|
||||
t.Error("expected cacheAge to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugAffinityPrefixFilter(t *testing.T) {
|
||||
now := time.Now()
|
||||
edge1 := newEdge("aaaa1111", "bbbb2222", "bb", 50, now)
|
||||
edge2 := newEdge("aaaa1111", "dddd3333", "dd", 30, now)
|
||||
|
||||
graph := makeTestGraph(edge1, edge2)
|
||||
srv := makeTestServer(graph)
|
||||
srv.cfg = &Config{APIKey: "test-key"}
|
||||
|
||||
r, _ := http.NewRequest("GET", "/api/debug/affinity?prefix=bb", nil)
|
||||
r.Header.Set("X-API-Key", "test-key")
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleDebugAffinity(w, r)
|
||||
|
||||
var resp DebugAffinityResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Edges) != 1 {
|
||||
t.Errorf("expected 1 edge with prefix filter, got %d", len(resp.Edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugAffinityNodeFilter(t *testing.T) {
|
||||
now := time.Now()
|
||||
edge1 := newEdge("aaaa1111", "bbbb2222", "bb", 50, now)
|
||||
edge2 := newEdge("cccc3333", "dddd4444", "dd", 30, now)
|
||||
|
||||
graph := makeTestGraph(edge1, edge2)
|
||||
srv := makeTestServer(graph)
|
||||
srv.cfg = &Config{APIKey: "test-key"}
|
||||
|
||||
r, _ := http.NewRequest("GET", "/api/debug/affinity?node=aaaa1111", nil)
|
||||
r.Header.Set("X-API-Key", "test-key")
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleDebugAffinity(w, r)
|
||||
|
||||
var resp DebugAffinityResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Edges) != 1 {
|
||||
t.Errorf("expected 1 edge with node filter, got %d", len(resp.Edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugAffinityRequiresAuth(t *testing.T) {
|
||||
graph := makeTestGraph()
|
||||
srv := makeTestServer(graph)
|
||||
srv.cfg = &Config{APIKey: "secret"}
|
||||
|
||||
r, _ := http.NewRequest("GET", "/api/debug/affinity", nil)
|
||||
r.Header.Set("X-API-Key", "wrong-key")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Use the requireAPIKey middleware
|
||||
handler := srv.requireAPIKey(http.HandlerFunc(srv.handleDebugAffinity))
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredLogging(t *testing.T) {
|
||||
// Test that the logging function in the graph actually works
|
||||
var logMessages []string
|
||||
g := NewNeighborGraph()
|
||||
g.logFn = func(prefix, msg string) {
|
||||
logMessages = append(logMessages, "[affinity] resolve "+prefix+": "+msg)
|
||||
}
|
||||
|
||||
// Add some edges that would trigger disambiguation
|
||||
now := time.Now()
|
||||
// Add resolved edges for neighbor sets
|
||||
g.mu.Lock()
|
||||
// Node aaaa has neighbors: xxxx, yyyy
|
||||
e1 := &NeighborEdge{NodeA: "aaaa", NodeB: "xxxx", Prefix: "xx", Count: 10, Observers: map[string]bool{}, FirstSeen: now, LastSeen: now}
|
||||
g.edges[makeEdgeKey("aaaa", "xxxx")] = e1
|
||||
g.byNode["aaaa"] = append(g.byNode["aaaa"], e1)
|
||||
g.byNode["xxxx"] = append(g.byNode["xxxx"], e1)
|
||||
|
||||
e2 := &NeighborEdge{NodeA: "aaaa", NodeB: "yyyy", Prefix: "yy", Count: 10, Observers: map[string]bool{}, FirstSeen: now, LastSeen: now}
|
||||
g.edges[makeEdgeKey("aaaa", "yyyy")] = e2
|
||||
g.byNode["aaaa"] = append(g.byNode["aaaa"], e2)
|
||||
g.byNode["yyyy"] = append(g.byNode["yyyy"], e2)
|
||||
|
||||
// Candidate cccc1 also has neighbor xxxx, yyyy (high Jaccard with aaaa)
|
||||
e3 := &NeighborEdge{NodeA: "cccc1", NodeB: "xxxx", Prefix: "xx", Count: 10, Observers: map[string]bool{}, FirstSeen: now, LastSeen: now}
|
||||
g.edges[makeEdgeKey("cccc1", "xxxx")] = e3
|
||||
g.byNode["cccc1"] = append(g.byNode["cccc1"], e3)
|
||||
|
||||
e4 := &NeighborEdge{NodeA: "cccc1", NodeB: "yyyy", Prefix: "yy", Count: 10, Observers: map[string]bool{}, FirstSeen: now, LastSeen: now}
|
||||
g.edges[makeEdgeKey("cccc1", "yyyy")] = e4
|
||||
g.byNode["cccc1"] = append(g.byNode["cccc1"], e4)
|
||||
|
||||
// Candidate cccc2 has no neighbors (low Jaccard)
|
||||
// Add ambiguous edge: aaaa ↔ prefix:cc with candidates [cccc1, cccc2]
|
||||
ambigEdge := &NeighborEdge{
|
||||
NodeA: "aaaa", NodeB: "", Prefix: "cc", Count: 5,
|
||||
Ambiguous: true, Candidates: []string{"cccc1", "cccc2"},
|
||||
Observers: map[string]bool{}, FirstSeen: now, LastSeen: now,
|
||||
}
|
||||
ambigKey := makeEdgeKey("aaaa", "prefix:cc")
|
||||
g.edges[ambigKey] = ambigEdge
|
||||
g.byNode["aaaa"] = append(g.byNode["aaaa"], ambigEdge)
|
||||
g.mu.Unlock()
|
||||
|
||||
// Now run disambiguate — this should trigger logging
|
||||
g.disambiguate()
|
||||
|
||||
if len(logMessages) == 0 {
|
||||
t.Error("expected at least one log message from disambiguation")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, msg := range logMessages {
|
||||
if strings.Contains(msg, "[affinity] resolve cc:") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected log message about prefix 'cc', got: %v", logMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColdStartCoverage(t *testing.T) {
|
||||
edges := []*NeighborEdge{
|
||||
{Prefix: "aa", Count: 5},
|
||||
{Prefix: "bb", Count: 3},
|
||||
{Prefix: "cc", Count: 1}, // below threshold
|
||||
}
|
||||
|
||||
srv := &Server{cfg: &Config{}}
|
||||
coverage := srv.computeColdStartCoverage(edges)
|
||||
|
||||
// 2 out of 3 prefixes have >=3 observations = 66.7%
|
||||
if coverage < 66.0 || coverage > 67.0 {
|
||||
t.Errorf("expected ~66.7%% coverage, got %.1f%%", coverage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugResponseShape(t *testing.T) {
|
||||
edge := newEdge("aaaa1111", "bbbb2222", "bb", 50, time.Now())
|
||||
edge.Resolved = true
|
||||
|
||||
graph := makeTestGraph(edge)
|
||||
srv := makeTestServer(graph)
|
||||
srv.cfg = &Config{APIKey: "test-key"}
|
||||
|
||||
r, _ := http.NewRequest("GET", "/api/debug/affinity", nil)
|
||||
r.Header.Set("X-API-Key", "test-key")
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleDebugAffinity(w, r)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
// Verify top-level keys
|
||||
for _, key := range []string{"edges", "resolutions", "stats"} {
|
||||
if _, ok := resp[key]; !ok {
|
||||
t.Errorf("missing top-level key: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
stats := resp["stats"].(map[string]interface{})
|
||||
for _, key := range []string{"totalEdges", "totalNodes", "resolvedCount", "ambiguousCount", "unresolvedCount", "avgConfidence", "coldStartCoverage", "cacheAge", "lastRebuild"} {
|
||||
if _, ok := stats[key]; !ok {
|
||||
t.Errorf("missing stats key: %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// After this many observations, count contributes max weight to the score.
|
||||
affinitySaturationCount = 100
|
||||
// Time-decay half-life: 7 days.
|
||||
affinityHalfLifeHours = 168.0
|
||||
// Cache TTL for the built graph.
|
||||
neighborGraphTTL = 5 * time.Minute
|
||||
// Auto-resolve confidence: best must be >= this factor × second-best.
|
||||
affinityConfidenceRatio = 3.0
|
||||
// Minimum observation count to auto-resolve.
|
||||
affinityMinObservations = 3
|
||||
)
|
||||
|
||||
// affinityLambda = ln(2) / half-life-hours, precomputed.
|
||||
var affinityLambda = math.Ln2 / affinityHalfLifeHours
|
||||
|
||||
// ─── Data model ────────────────────────────────────────────────────────────────
|
||||
|
||||
// edgeKey is the canonical key for an undirected edge (A < B lexicographically).
|
||||
// For ambiguous edges where NodeB is unknown, B is the raw prefix prefixed with "prefix:".
|
||||
type edgeKey struct {
|
||||
A, B string
|
||||
}
|
||||
|
||||
func makeEdgeKey(a, b string) edgeKey {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return edgeKey{A: a, B: b}
|
||||
}
|
||||
|
||||
// NeighborEdge represents a weighted, undirected first-hop neighbor relationship.
|
||||
type NeighborEdge struct {
|
||||
NodeA string // full pubkey
|
||||
NodeB string // full pubkey, or "" if unresolved/ambiguous
|
||||
Prefix string // raw hop prefix that established this edge
|
||||
Count int // total observations
|
||||
FirstSeen time.Time //
|
||||
LastSeen time.Time //
|
||||
SNRSum float64 // running sum for average
|
||||
SNRCount int // how many SNR samples
|
||||
Observers map[string]bool // observer pubkeys that witnessed
|
||||
Ambiguous bool // multiple candidates or zero candidates
|
||||
Candidates []string // candidate pubkeys when ambiguous
|
||||
Resolved bool // true if auto-resolved via Jaccard
|
||||
}
|
||||
|
||||
// Score computes the affinity score at query time with time decay.
|
||||
func (e *NeighborEdge) Score(now time.Time) float64 {
|
||||
countFactor := math.Min(1.0, float64(e.Count)/float64(affinitySaturationCount))
|
||||
hoursSince := now.Sub(e.LastSeen).Hours()
|
||||
if hoursSince < 0 {
|
||||
hoursSince = 0
|
||||
}
|
||||
decay := math.Exp(-affinityLambda * hoursSince)
|
||||
return countFactor * decay
|
||||
}
|
||||
|
||||
// AvgSNR returns the average SNR, or 0 if no samples.
|
||||
func (e *NeighborEdge) AvgSNR() float64 {
|
||||
if e.SNRCount == 0 {
|
||||
return 0
|
||||
}
|
||||
return e.SNRSum / float64(e.SNRCount)
|
||||
}
|
||||
|
||||
// ─── NeighborGraph ─────────────────────────────────────────────────────────────
|
||||
|
||||
// NeighborGraph is a cached, in-memory first-hop neighbor affinity graph.
|
||||
type NeighborGraph struct {
|
||||
mu sync.RWMutex
|
||||
edges map[edgeKey]*NeighborEdge
|
||||
byNode map[string][]*NeighborEdge // pubkey → edges involving this node
|
||||
builtAt time.Time
|
||||
logFn func(prefix, msg string) // optional structured logging callback
|
||||
}
|
||||
|
||||
// NewNeighborGraph creates an empty graph.
|
||||
func NewNeighborGraph() *NeighborGraph {
|
||||
return &NeighborGraph{
|
||||
edges: make(map[edgeKey]*NeighborEdge),
|
||||
byNode: make(map[string][]*NeighborEdge),
|
||||
}
|
||||
}
|
||||
|
||||
// Neighbors returns all edges for a given node pubkey.
|
||||
func (g *NeighborGraph) Neighbors(pubkey string) []*NeighborEdge {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.byNode[strings.ToLower(pubkey)]
|
||||
}
|
||||
|
||||
// AllEdges returns all edges in the graph.
|
||||
func (g *NeighborGraph) AllEdges() []*NeighborEdge {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
out := make([]*NeighborEdge, 0, len(g.edges))
|
||||
for _, e := range g.edges {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsStale returns true if the graph cache has expired.
|
||||
func (g *NeighborGraph) IsStale() bool {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.builtAt.IsZero() || time.Since(g.builtAt) > neighborGraphTTL
|
||||
}
|
||||
|
||||
// ─── Builder ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// BuildFromStore constructs the neighbor graph from all packets in the store.
|
||||
// The store's read-lock must NOT be held by the caller.
|
||||
func BuildFromStore(store *PacketStore) *NeighborGraph {
|
||||
return BuildFromStoreWithLog(store, false)
|
||||
}
|
||||
|
||||
// cachedToLower returns strings.ToLower(s), caching results to avoid
|
||||
// repeated allocations for the same pubkey string.
|
||||
func cachedToLower(cache map[string]string, s string) string {
|
||||
if v, ok := cache[s]; ok {
|
||||
return v
|
||||
}
|
||||
v := strings.ToLower(s)
|
||||
cache[s] = v
|
||||
return v
|
||||
}
|
||||
|
||||
// BuildFromStoreWithLog constructs the neighbor graph, optionally logging disambiguation decisions.
|
||||
func BuildFromStoreWithLog(store *PacketStore, enableLog bool) *NeighborGraph {
|
||||
g := NewNeighborGraph()
|
||||
if enableLog {
|
||||
g.logFn = func(prefix, msg string) {
|
||||
log.Printf("[affinity] resolve %s: %s", prefix, msg)
|
||||
}
|
||||
}
|
||||
|
||||
store.mu.RLock()
|
||||
// Snapshot what we need under lock.
|
||||
packets := make([]*StoreTx, len(store.packets))
|
||||
copy(packets, store.packets)
|
||||
store.mu.RUnlock()
|
||||
|
||||
// Build prefix map for candidate resolution.
|
||||
// Use cached nodes+PM (avoids DB call if cache is fresh).
|
||||
_, pm := store.getCachedNodesAndPM()
|
||||
|
||||
// Local cache for strings.ToLower — pubkeys are immutable and repeat
|
||||
// across hundreds of thousands of observations.
|
||||
lowerCache := make(map[string]string, 256)
|
||||
|
||||
// Phase 1: Extract edges from every transmission + observation.
|
||||
for _, tx := range packets {
|
||||
isAdvert := tx.PayloadType != nil && *tx.PayloadType == 4
|
||||
fromNode := extractFromNode(tx)
|
||||
// Pre-compute lowered originator once per tx (not per observation).
|
||||
fromLower := ""
|
||||
if fromNode != "" {
|
||||
fromLower = cachedToLower(lowerCache, fromNode)
|
||||
}
|
||||
|
||||
for _, obs := range tx.Observations {
|
||||
path := parsePathJSON(obs.PathJSON)
|
||||
observerPK := cachedToLower(lowerCache, obs.ObserverID)
|
||||
|
||||
if len(path) == 0 {
|
||||
// Zero-hop
|
||||
if isAdvert && fromLower != "" {
|
||||
if fromLower != observerPK { // self-edge guard
|
||||
g.upsertEdge(fromLower, observerPK, "", observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Edge 1: originator ↔ path[0] — ADVERTs only
|
||||
if isAdvert && fromLower != "" {
|
||||
firstHop := cachedToLower(lowerCache, path[0])
|
||||
if fromLower != firstHop { // self-edge guard (shouldn't happen but spec says check)
|
||||
candidates := pm.m[firstHop]
|
||||
g.upsertEdgeWithCandidates(fromLower, firstHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp), lowerCache)
|
||||
}
|
||||
}
|
||||
|
||||
// Edge 2: observer ↔ path[last] — ALL packet types
|
||||
lastHop := cachedToLower(lowerCache, path[len(path)-1])
|
||||
if observerPK != lastHop { // self-edge guard
|
||||
candidates := pm.m[lastHop]
|
||||
g.upsertEdgeWithCandidates(observerPK, lastHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp), lowerCache)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Disambiguation via Jaccard similarity.
|
||||
g.disambiguate()
|
||||
|
||||
g.mu.Lock()
|
||||
g.builtAt = time.Now()
|
||||
g.mu.Unlock()
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// extractFromNode pulls the originator pubkey from a StoreTx's DecodedJSON.
|
||||
// ADVERTs use "pubKey", other packets may use "from_node" or "from".
|
||||
// Uses the cached ParsedDecoded() accessor to avoid repeated json.Unmarshal.
|
||||
func extractFromNode(tx *StoreTx) string {
|
||||
decoded := tx.ParsedDecoded()
|
||||
if decoded == nil {
|
||||
return ""
|
||||
}
|
||||
// ADVERTs store the originator pubkey as "pubKey"; other packets may use
|
||||
// "from_node" or "from". Check all three so we never miss the originator.
|
||||
for _, field := range []string{"pubKey", "from_node", "from"} {
|
||||
if v, ok := decoded[field]; ok {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// jsonUnmarshalFast is a thin wrapper; could be optimized later.
|
||||
func jsonUnmarshalFast(data string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(data), v)
|
||||
}
|
||||
|
||||
// upsertEdge adds/updates an edge between two fully-known pubkeys.
|
||||
func (g *NeighborGraph) upsertEdge(pubkeyA, pubkeyB, prefix, observer string, snr *float64, ts time.Time) {
|
||||
key := makeEdgeKey(pubkeyA, pubkeyB)
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
e, exists := g.edges[key]
|
||||
if !exists {
|
||||
e = &NeighborEdge{
|
||||
NodeA: key.A,
|
||||
NodeB: key.B,
|
||||
Prefix: prefix,
|
||||
Observers: make(map[string]bool),
|
||||
FirstSeen: ts,
|
||||
LastSeen: ts,
|
||||
}
|
||||
g.edges[key] = e
|
||||
g.byNode[key.A] = append(g.byNode[key.A], e)
|
||||
g.byNode[key.B] = append(g.byNode[key.B], e)
|
||||
}
|
||||
|
||||
e.Count++
|
||||
if ts.After(e.LastSeen) {
|
||||
e.LastSeen = ts
|
||||
}
|
||||
if ts.Before(e.FirstSeen) {
|
||||
e.FirstSeen = ts
|
||||
}
|
||||
if snr != nil {
|
||||
e.SNRSum += *snr
|
||||
e.SNRCount++
|
||||
}
|
||||
if observer != "" {
|
||||
e.Observers[observer] = true
|
||||
}
|
||||
}
|
||||
|
||||
// upsertEdgeWithCandidates handles prefix-based edges that may be ambiguous.
|
||||
func (g *NeighborGraph) upsertEdgeWithCandidates(knownPK, prefix string, candidates []nodeInfo, observer string, snr *float64, ts time.Time, lc map[string]string) {
|
||||
if len(candidates) == 1 {
|
||||
resolved := cachedToLower(lc, candidates[0].PublicKey)
|
||||
if resolved == knownPK {
|
||||
return // self-edge guard
|
||||
}
|
||||
g.upsertEdge(knownPK, resolved, prefix, observer, snr, ts)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out self from candidates
|
||||
filtered := make([]string, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
pk := cachedToLower(lc, c.PublicKey)
|
||||
if pk != knownPK {
|
||||
filtered = append(filtered, pk)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 1 {
|
||||
g.upsertEdge(knownPK, filtered[0], prefix, observer, snr, ts)
|
||||
return
|
||||
}
|
||||
|
||||
// Ambiguous or orphan: use prefix-based key
|
||||
pseudoB := "prefix:" + prefix
|
||||
key := makeEdgeKey(knownPK, pseudoB)
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
e, exists := g.edges[key]
|
||||
if !exists {
|
||||
e = &NeighborEdge{
|
||||
NodeA: key.A,
|
||||
NodeB: "",
|
||||
Prefix: prefix,
|
||||
Observers: make(map[string]bool),
|
||||
Ambiguous: true,
|
||||
Candidates: filtered,
|
||||
FirstSeen: ts,
|
||||
LastSeen: ts,
|
||||
}
|
||||
g.edges[key] = e
|
||||
g.byNode[knownPK] = append(g.byNode[knownPK], e)
|
||||
}
|
||||
|
||||
e.Count++
|
||||
if ts.After(e.LastSeen) {
|
||||
e.LastSeen = ts
|
||||
}
|
||||
if ts.Before(e.FirstSeen) {
|
||||
e.FirstSeen = ts
|
||||
}
|
||||
if snr != nil {
|
||||
e.SNRSum += *snr
|
||||
e.SNRCount++
|
||||
}
|
||||
if observer != "" {
|
||||
e.Observers[observer] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Disambiguation ────────────────────────────────────────────────────────────
|
||||
|
||||
// disambiguate resolves ambiguous edges using Jaccard similarity of neighbor sets.
|
||||
// Only fully-resolved edges are used as evidence (transitivity poisoning guard).
|
||||
func (g *NeighborGraph) disambiguate() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
// Build resolved neighbor sets: for each node, collect the set of nodes
|
||||
// it has fully-resolved (non-ambiguous) edges with.
|
||||
resolvedNeighbors := make(map[string]map[string]bool)
|
||||
for _, e := range g.edges {
|
||||
if e.Ambiguous || e.NodeB == "" {
|
||||
continue
|
||||
}
|
||||
if resolvedNeighbors[e.NodeA] == nil {
|
||||
resolvedNeighbors[e.NodeA] = make(map[string]bool)
|
||||
}
|
||||
if resolvedNeighbors[e.NodeB] == nil {
|
||||
resolvedNeighbors[e.NodeB] = make(map[string]bool)
|
||||
}
|
||||
resolvedNeighbors[e.NodeA][e.NodeB] = true
|
||||
resolvedNeighbors[e.NodeB][e.NodeA] = true
|
||||
}
|
||||
|
||||
// Try to resolve each ambiguous edge.
|
||||
for key, e := range g.edges {
|
||||
if !e.Ambiguous || len(e.Candidates) < 2 {
|
||||
continue
|
||||
}
|
||||
if e.Count < affinityMinObservations {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine the known node (the one that's a real pubkey, not the prefix side).
|
||||
knownNode := e.NodeA
|
||||
if strings.HasPrefix(e.NodeA, "prefix:") {
|
||||
knownNode = e.NodeB
|
||||
}
|
||||
// If knownNode is empty (shouldn't happen for ambiguous edges with candidates), skip.
|
||||
if knownNode == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
knownNeighbors := resolvedNeighbors[knownNode]
|
||||
|
||||
type scored struct {
|
||||
pubkey string
|
||||
jaccard float64
|
||||
}
|
||||
var scores []scored
|
||||
|
||||
for _, cand := range e.Candidates {
|
||||
candNeighbors := resolvedNeighbors[cand]
|
||||
j := jaccardSimilarity(knownNeighbors, candNeighbors)
|
||||
scores = append(scores, scored{cand, j})
|
||||
}
|
||||
|
||||
if len(scores) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find best and second-best.
|
||||
best, secondBest := scores[0], scores[1]
|
||||
if secondBest.jaccard > best.jaccard {
|
||||
best, secondBest = secondBest, best
|
||||
}
|
||||
for i := 2; i < len(scores); i++ {
|
||||
if scores[i].jaccard > best.jaccard {
|
||||
secondBest = best
|
||||
best = scores[i]
|
||||
} else if scores[i].jaccard > secondBest.jaccard {
|
||||
secondBest = scores[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resolve only if best >= 3× second-best AND enough observations.
|
||||
if secondBest.jaccard == 0 {
|
||||
// If second-best is 0 and best > 0, ratio is infinite → resolve.
|
||||
if best.jaccard > 0 {
|
||||
if g.logFn != nil {
|
||||
g.logFn(e.Prefix, fmt.Sprintf("%s score=%d Jaccard=%.2f vs %s score=%d Jaccard=%.2f → neighbor_affinity (ratio ∞)",
|
||||
best.pubkey[:minLen(best.pubkey, 8)], e.Count, best.jaccard,
|
||||
secondBest.pubkey[:minLen(secondBest.pubkey, 8)], e.Count, secondBest.jaccard))
|
||||
}
|
||||
g.resolveEdge(key, e, knownNode, best.pubkey)
|
||||
}
|
||||
} else if best.jaccard/secondBest.jaccard >= affinityConfidenceRatio {
|
||||
ratio := best.jaccard / secondBest.jaccard
|
||||
if g.logFn != nil {
|
||||
g.logFn(e.Prefix, fmt.Sprintf("%s score=%d Jaccard=%.2f vs %s score=%d Jaccard=%.2f → neighbor_affinity (ratio %.1f×)",
|
||||
best.pubkey[:minLen(best.pubkey, 8)], e.Count, best.jaccard,
|
||||
secondBest.pubkey[:minLen(secondBest.pubkey, 8)], e.Count, secondBest.jaccard, ratio))
|
||||
}
|
||||
g.resolveEdge(key, e, knownNode, best.pubkey)
|
||||
} else {
|
||||
// Ambiguous
|
||||
if g.logFn != nil {
|
||||
ratio := 0.0
|
||||
if secondBest.jaccard > 0 {
|
||||
ratio = best.jaccard / secondBest.jaccard
|
||||
}
|
||||
g.logFn(e.Prefix, fmt.Sprintf("scores too close (Jaccard %.2f vs %.2f, ratio %.1f×) → ambiguous, returning %d candidates",
|
||||
best.jaccard, secondBest.jaccard, ratio, len(e.Candidates)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEdge converts an ambiguous edge to a resolved one.
|
||||
// Must be called with g.mu held.
|
||||
func (g *NeighborGraph) resolveEdge(oldKey edgeKey, e *NeighborEdge, knownNode, resolvedPK string) {
|
||||
// Remove old edge.
|
||||
delete(g.edges, oldKey)
|
||||
g.removeFromByNode(oldKey.A, e)
|
||||
g.removeFromByNode(oldKey.B, e)
|
||||
|
||||
// Update edge.
|
||||
newKey := makeEdgeKey(knownNode, resolvedPK)
|
||||
e.NodeA = newKey.A
|
||||
e.NodeB = newKey.B
|
||||
e.Ambiguous = false
|
||||
e.Resolved = true
|
||||
|
||||
// Merge with existing edge if any.
|
||||
if existing, ok := g.edges[newKey]; ok {
|
||||
existing.Count += e.Count
|
||||
if e.LastSeen.After(existing.LastSeen) {
|
||||
existing.LastSeen = e.LastSeen
|
||||
}
|
||||
if e.FirstSeen.Before(existing.FirstSeen) {
|
||||
existing.FirstSeen = e.FirstSeen
|
||||
}
|
||||
existing.SNRSum += e.SNRSum
|
||||
existing.SNRCount += e.SNRCount
|
||||
for obs := range e.Observers {
|
||||
existing.Observers[obs] = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
g.edges[newKey] = e
|
||||
g.byNode[newKey.A] = append(g.byNode[newKey.A], e)
|
||||
g.byNode[newKey.B] = append(g.byNode[newKey.B], e)
|
||||
}
|
||||
|
||||
// removeFromByNode removes an edge from the byNode index for the given key.
|
||||
func (g *NeighborGraph) removeFromByNode(nodeKey string, edge *NeighborEdge) {
|
||||
edges := g.byNode[nodeKey]
|
||||
for i, e := range edges {
|
||||
if e == edge {
|
||||
g.byNode[nodeKey] = append(edges[:i], edges[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jaccardSimilarity computes |A ∩ B| / |A ∪ B|.
|
||||
func jaccardSimilarity(a, b map[string]bool) float64 {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
intersection := 0
|
||||
for k := range a {
|
||||
if b[k] {
|
||||
intersection++
|
||||
}
|
||||
}
|
||||
union := len(a) + len(b) - intersection
|
||||
if union == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(intersection) / float64(union)
|
||||
}
|
||||
|
||||
// parseTimestamp parses a timestamp string into time.Time.
|
||||
func parseTimestamp(s string) time.Time {
|
||||
// Try common formats.
|
||||
for _, fmt := range []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05.000Z",
|
||||
} {
|
||||
if t, err := time.Parse(fmt, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
|
||||
// minLen returns the smaller of n and len(s).
|
||||
func minLen(s string, n int) int {
|
||||
if len(s) < n {
|
||||
return len(s)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -1,836 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// ngTestStore creates a minimal PacketStore with injected nodes and packets.
|
||||
func ngTestStore(nodes []nodeInfo, packets []*StoreTx) *PacketStore {
|
||||
if nodes == nil {
|
||||
nodes = []nodeInfo{}
|
||||
}
|
||||
if packets == nil {
|
||||
packets = []*StoreTx{}
|
||||
}
|
||||
ps := &PacketStore{
|
||||
packets: packets,
|
||||
byHash: make(map[string]*StoreTx),
|
||||
byTxID: make(map[int]*StoreTx),
|
||||
byObsID: make(map[int]*StoreObs),
|
||||
byObserver: make(map[string][]*StoreObs),
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
byPayloadType: make(map[int][]*StoreTx),
|
||||
rfCache: make(map[string]*cachedResult),
|
||||
topoCache: make(map[string]*cachedResult),
|
||||
hashCache: make(map[string]*cachedResult),
|
||||
collisionCache: make(map[string]*cachedResult),
|
||||
chanCache: make(map[string]*cachedResult),
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
spIndex: make(map[string]int),
|
||||
}
|
||||
ps.nodeCache = nodes
|
||||
ps.nodePM = buildPrefixMap(nodes)
|
||||
ps.nodeCacheTime = time.Now().Add(1 * time.Hour)
|
||||
return ps
|
||||
}
|
||||
|
||||
func ngIntPtr(v int) *int { return &v }
|
||||
func ngFloatPtr(v float64) *float64 { return &v }
|
||||
|
||||
func ngMakeTx(id int, payloadType int, decodedJSON string, obs []*StoreObs) *StoreTx {
|
||||
tx := &StoreTx{
|
||||
ID: id,
|
||||
PayloadType: ngIntPtr(payloadType),
|
||||
DecodedJSON: decodedJSON,
|
||||
Observations: obs,
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func ngMakeObs(observerID, pathJSON, timestamp string, snr *float64) *StoreObs {
|
||||
return &StoreObs{
|
||||
ObserverID: observerID,
|
||||
PathJSON: pathJSON,
|
||||
Timestamp: timestamp,
|
||||
SNR: snr,
|
||||
}
|
||||
}
|
||||
|
||||
func ngFromNodeJSON(pubkey string) string {
|
||||
b, _ := json.Marshal(map[string]string{"from_node": pubkey})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
var now = time.Now()
|
||||
var nowStr = now.UTC().Format(time.RFC3339)
|
||||
var weekAgoStr = now.Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
var monthAgoStr = now.Add(-30 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBuildNeighborGraph_EmptyStore(t *testing.T) {
|
||||
store := ngTestStore(nil, nil)
|
||||
g := BuildFromStore(store)
|
||||
if len(g.edges) != 0 {
|
||||
t.Errorf("expected 0 edges, got %d", len(g.edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_AdvertSingleHopPath(t *testing.T) {
|
||||
// ADVERT from X, path=["R1_prefix"] → edges: X↔R1 and Observer↔R1
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, ngFloatPtr(-10)),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should have 2 edges: X↔R1 and Observer↔R1
|
||||
// But since path has 1 element, path[0]==path[last], so for ADVERTs
|
||||
// both edge types point to the same hop. X↔R1 and Obs↔R1 = 2 edges.
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
|
||||
// Check X↔R1 exists
|
||||
found := false
|
||||
for _, e := range edges {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") ||
|
||||
(e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing originator↔path[0] edge (X↔R1)")
|
||||
}
|
||||
|
||||
// Check Observer↔R1 exists
|
||||
found = false
|
||||
for _, e := range edges {
|
||||
if (e.NodeA == "obs00001" && e.NodeB == "r1aabbcc") ||
|
||||
(e.NodeA == "r1aabbcc" && e.NodeB == "obs00001") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing observer↔path[last] edge (Observer↔R1)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_AdvertMultiHopPath(t *testing.T) {
|
||||
// ADVERT from X, path=["R1","R2"] → X↔R1 and Observer↔R2
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "r2ddeeff", Name: "R2"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
|
||||
// X↔R1
|
||||
hasXR1 := false
|
||||
hasObsR2 := false
|
||||
for _, e := range edges {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
hasXR1 = true
|
||||
}
|
||||
if (e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001") {
|
||||
hasObsR2 = true
|
||||
}
|
||||
}
|
||||
if !hasXR1 {
|
||||
t.Error("missing X↔R1 edge")
|
||||
}
|
||||
if !hasObsR2 {
|
||||
t.Error("missing Observer↔R2 edge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_AdvertZeroHop(t *testing.T) {
|
||||
// ADVERT from X, path=[] → X↔Observer direct edge
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `[]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge, got %d", len(edges))
|
||||
}
|
||||
e := edges[0]
|
||||
if !((e.NodeA == "aaaa1111" && e.NodeB == "obs00001") || (e.NodeA == "obs00001" && e.NodeB == "aaaa1111")) {
|
||||
t.Errorf("expected X↔Observer edge, got %s↔%s", e.NodeA, e.NodeB)
|
||||
}
|
||||
if e.Ambiguous {
|
||||
t.Error("zero-hop edge should not be ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_NonAdvertEmptyPath(t *testing.T) {
|
||||
// Non-ADVERT, path=[] → no edges
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `[]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
if len(g.edges) != 0 {
|
||||
t.Errorf("expected 0 edges for non-ADVERT empty path, got %d", len(g.edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_NonAdvertOnlyObserverEdge(t *testing.T) {
|
||||
// Non-ADVERT with path=["R1","R2"] → only Observer↔R2, NO originator edge
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "r2ddeeff", Name: "R2"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge, got %d", len(edges))
|
||||
}
|
||||
e := edges[0]
|
||||
if !((e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001")) {
|
||||
t.Errorf("expected Observer↔R2 edge, got %s↔%s", e.NodeA, e.NodeB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_NonAdvertSingleHop(t *testing.T) {
|
||||
// Non-ADVERT with path=["R1"] → Observer↔R1 only
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge, got %d", len(edges))
|
||||
}
|
||||
e := edges[0]
|
||||
if !((e.NodeA == "obs00001" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "obs00001")) {
|
||||
t.Errorf("expected Observer↔R1, got %s↔%s", e.NodeA, e.NodeB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_HashCollision(t *testing.T) {
|
||||
// Two nodes share prefix "a3" → ambiguous edge
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "a3bb1111", Name: "CandidateA"},
|
||||
{PublicKey: "a3bb2222", Name: "CandidateB"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["a3bb"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should have ambiguous edges
|
||||
var ambigCount int
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Ambiguous {
|
||||
ambigCount++
|
||||
if len(e.Candidates) < 2 {
|
||||
t.Errorf("expected >=2 candidates, got %d", len(e.Candidates))
|
||||
}
|
||||
}
|
||||
}
|
||||
if ambigCount == 0 {
|
||||
t.Error("expected at least one ambiguous edge for hash collision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_JaccardScoring(t *testing.T) {
|
||||
// Test Jaccard similarity computation directly
|
||||
a := map[string]bool{"x": true, "y": true, "z": true}
|
||||
b := map[string]bool{"y": true, "z": true, "w": true}
|
||||
j := jaccardSimilarity(a, b)
|
||||
// intersection = {y, z} = 2, union = {x, y, z, w} = 4 → 0.5
|
||||
if math.Abs(j-0.5) > 0.001 {
|
||||
t.Errorf("expected Jaccard 0.5, got %f", j)
|
||||
}
|
||||
|
||||
// Empty sets
|
||||
j = jaccardSimilarity(nil, nil)
|
||||
if j != 0 {
|
||||
t.Errorf("expected 0 for empty sets, got %f", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_ConfidenceAutoResolve(t *testing.T) {
|
||||
// Setup: NodeX has known neighbors N1, N2, N3 (resolved edges).
|
||||
// CandidateA also has known neighbors N1, N2, N3 (high Jaccard with X).
|
||||
// CandidateB has no known neighbors (Jaccard = 0).
|
||||
// An ambiguous edge X↔prefix "a3" with candidates [A, B] should auto-resolve to A.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "n1111111", Name: "N1"},
|
||||
{PublicKey: "n2222222", Name: "N2"},
|
||||
{PublicKey: "n3333333", Name: "N3"},
|
||||
{PublicKey: "a3001111", Name: "CandidateA"},
|
||||
{PublicKey: "a3002222", Name: "CandidateB"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
// Create resolved edges: X↔N1, X↔N2, X↔N3, A↔N1, A↔N2, A↔N3
|
||||
// Then an ambiguous edge X↔"a300" prefix with 3+ observations.
|
||||
var txs []*StoreTx
|
||||
txID := 1
|
||||
|
||||
// X sends ADVERTs through N1, N2, N3
|
||||
for _, nhop := range []string{"n111", "n222", "n333"} {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["`+nhop+`"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
// CandidateA sends ADVERTs through N1, N2, N3
|
||||
for _, nhop := range []string{"n111", "n222", "n333"} {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3001111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["`+nhop+`"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
// Ambiguous edge: X sends ADVERTs with path[0]="a300" (matches both candidates)
|
||||
// Need 3+ observations for confidence threshold.
|
||||
for i := 0; i < 3; i++ {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["a300"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
store := ngTestStore(nodes, txs)
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// The ambiguous edge X↔a300 should have been resolved to CandidateA
|
||||
neighbors := g.Neighbors("aaaa1111")
|
||||
foundA := false
|
||||
for _, e := range neighbors {
|
||||
other := e.NodeB
|
||||
if e.NodeA != "aaaa1111" {
|
||||
other = e.NodeA
|
||||
}
|
||||
if other == "a3001111" {
|
||||
foundA = true
|
||||
if e.Ambiguous {
|
||||
t.Error("edge should have been resolved (not ambiguous)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundA {
|
||||
t.Error("expected edge X↔CandidateA to be auto-resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_EqualScoresAmbiguous(t *testing.T) {
|
||||
// Two candidates with identical neighbor sets → should NOT auto-resolve.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "n1111111", Name: "N1"},
|
||||
{PublicKey: "a3001111", Name: "CandidateA"},
|
||||
{PublicKey: "a3002222", Name: "CandidateB"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
var txs []*StoreTx
|
||||
txID := 1
|
||||
|
||||
// X↔N1
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
|
||||
// Both candidates have same neighbor (N1)
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3001111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3002222"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
|
||||
// Ambiguous edge with 3+ observations
|
||||
for i := 0; i < 3; i++ {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["a300"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
store := ngTestStore(nodes, txs)
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should remain ambiguous
|
||||
var ambigFound bool
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Ambiguous && e.Prefix == "a300" {
|
||||
ambigFound = true
|
||||
}
|
||||
}
|
||||
if !ambigFound {
|
||||
t.Error("expected ambiguous edge to remain unresolved with equal scores")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_ObserverSelfEdgeGuard(t *testing.T) {
|
||||
// Observer's own prefix in path → should NOT create self-edge.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["obs0"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Check no self-edge for observer
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.NodeA == e.NodeB && e.NodeA == "obs00001" {
|
||||
t.Error("self-edge created for observer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_OrphanPrefix(t *testing.T) {
|
||||
// Path contains prefix matching zero nodes → edge recorded as unresolved.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["ff99"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should have ambiguous edges with empty candidates.
|
||||
var orphanFound bool
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Ambiguous && len(e.Candidates) == 0 {
|
||||
orphanFound = true
|
||||
if e.Prefix != "ff99" {
|
||||
t.Errorf("expected prefix ff99, got %s", e.Prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !orphanFound {
|
||||
t.Error("expected orphan prefix edge with empty candidates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_Fresh(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 100, LastSeen: time.Now()}
|
||||
s := e.Score(time.Now())
|
||||
if s < 0.99 || s > 1.0 {
|
||||
t.Errorf("expected score ≈ 1.0, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_Decayed(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 100, LastSeen: time.Now().Add(-7 * 24 * time.Hour)}
|
||||
s := e.Score(time.Now())
|
||||
// 7 days → half-life → ~0.5
|
||||
if math.Abs(s-0.5) > 0.05 {
|
||||
t.Errorf("expected score ≈ 0.5, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_LowCount(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 5, LastSeen: time.Now()}
|
||||
s := e.Score(time.Now())
|
||||
// 5/100 = 0.05
|
||||
if math.Abs(s-0.05) > 0.01 {
|
||||
t.Errorf("expected score ≈ 0.05, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_StaleAndLow(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 5, LastSeen: time.Now().Add(-30 * 24 * time.Hour)}
|
||||
s := e.Score(time.Now())
|
||||
// Very small
|
||||
if s > 0.01 {
|
||||
t.Errorf("expected score ≈ 0, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_CountAccumulation(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
var txs []*StoreTx
|
||||
for i := 0; i < 5; i++ {
|
||||
txs = append(txs, ngMakeTx(i+1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
|
||||
}))
|
||||
}
|
||||
|
||||
store := ngTestStore(nodes, txs)
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Check count on X↔R1 edge
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
if e.Count != 5 {
|
||||
t.Errorf("expected count 5, got %d", e.Count)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("X↔R1 edge not found")
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_MultipleObservers(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Obs1"},
|
||||
{PublicKey: "obs00002", Name: "Obs2"},
|
||||
}
|
||||
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
|
||||
ngMakeObs("obs00002", `["r1aa"]`, nowStr, nil),
|
||||
})
|
||||
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
if len(e.Observers) != 2 {
|
||||
t.Errorf("expected 2 observers, got %d", len(e.Observers))
|
||||
}
|
||||
if !e.Observers["obs00001"] || !e.Observers["obs00002"] {
|
||||
t.Error("missing expected observer")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("X↔R1 edge not found")
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_TimeDecayOldObservations(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, monthAgoStr, nil),
|
||||
})
|
||||
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
score := e.Score(time.Now())
|
||||
if score > 0.05 {
|
||||
t.Errorf("expected decayed score < 0.05, got %f", score)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("X↔R1 edge not found")
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_ADVERTOnlyConstraint(t *testing.T) {
|
||||
// Non-ADVERT: should NOT create originator↔path[0] edge, only observer↔path[last].
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "r2ddeeff", Name: "R2"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
for _, e := range g.AllEdges() {
|
||||
a, b := e.NodeA, e.NodeB
|
||||
if (a == "aaaa1111" && b == "r1aabbcc") || (a == "r1aabbcc" && b == "aaaa1111") {
|
||||
t.Error("non-ADVERT should NOT produce originator↔path[0] edge")
|
||||
}
|
||||
}
|
||||
|
||||
// Should have Observer↔R2
|
||||
found := false
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing Observer↔R2 edge from non-ADVERT")
|
||||
}
|
||||
}
|
||||
|
||||
// ngPubKeyJSON creates decoded JSON using the real ADVERT format ("pubKey" field).
|
||||
func ngPubKeyJSON(pubkey string) string {
|
||||
b, _ := json.Marshal(map[string]string{"pubKey": pubkey})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_AdvertPubKeyField(t *testing.T) {
|
||||
// Real ADVERTs use "pubKey", not "from_node". Verify the builder handles it.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "99bf37abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234", Name: "Originator"},
|
||||
{PublicKey: "r1aabbccdd001122334455667788990011223344556677889900112233445566", Name: "R1"},
|
||||
{PublicKey: "obs0000100112233445566778899001122334455667788990011223344556677", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngPubKeyJSON("99bf37abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234"), []*StoreObs{
|
||||
ngMakeObs("obs0000100112233445566778899001122334455667788990011223344556677", `["r1"]`, nowStr, ngFloatPtr(-8.5)),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) < 1 {
|
||||
t.Fatalf("expected >=1 edges from ADVERT with pubKey field, got %d", len(edges))
|
||||
}
|
||||
|
||||
// Check originator↔R1 edge exists
|
||||
found := false
|
||||
for _, e := range edges {
|
||||
a := e.NodeA
|
||||
b := e.NodeB
|
||||
orig := "99bf37abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234"
|
||||
r1 := "r1aabbccdd001122334455667788990011223344556677889900112233445566"
|
||||
if (a == orig && b == r1) || (a == r1 && b == orig) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing originator↔R1 edge when using pubKey field (real ADVERT format)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_OneByteHashPrefixes(t *testing.T) {
|
||||
// Real-world scenario: 1-byte hash prefixes with multiple candidates.
|
||||
// Should create edges (possibly ambiguous) rather than empty graph.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "c0dedad400000000000000000000000000000000000000000000000000000001", Name: "NodeC0-1"},
|
||||
{PublicKey: "c0dedad900000000000000000000000000000000000000000000000000000002", Name: "NodeC0-2"},
|
||||
{PublicKey: "a3bbccdd00000000000000000000000000000000000000000000000000000003", Name: "Originator"},
|
||||
{PublicKey: "obs1234500000000000000000000000000000000000000000000000000000004", Name: "Observer"},
|
||||
}
|
||||
// ADVERT from Originator with 1-byte path hop "c0"
|
||||
tx := ngMakeTx(1, 4, ngPubKeyJSON("a3bbccdd00000000000000000000000000000000000000000000000000000003"), []*StoreObs{
|
||||
ngMakeObs("obs1234500000000000000000000000000000000000000000000000000000004", `["c0"]`, nowStr, ngFloatPtr(-12)),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) == 0 {
|
||||
t.Fatal("expected non-empty edges for 1-byte hash prefix network, got 0")
|
||||
}
|
||||
|
||||
// The originator↔c0 edge should be ambiguous (2 candidates match "c0")
|
||||
var hasAmbig bool
|
||||
for _, e := range edges {
|
||||
if e.Ambiguous && e.Prefix == "c0" {
|
||||
hasAmbig = true
|
||||
if len(e.Candidates) != 2 {
|
||||
t.Errorf("expected 2 candidates for prefix c0, got %d", len(e.Candidates))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasAmbig {
|
||||
// Could be resolved if one candidate was filtered — check we got some edge
|
||||
t.Log("no ambiguous edge found, but edges exist — acceptable if resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraph_CacheTTL(t *testing.T) {
|
||||
g := NewNeighborGraph()
|
||||
if !g.IsStale() {
|
||||
t.Error("new graph should be stale")
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.builtAt = time.Now()
|
||||
g.mu.Unlock()
|
||||
if g.IsStale() {
|
||||
t.Error("just-built graph should not be stale")
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.builtAt = time.Now().Add(-2 * neighborGraphTTL)
|
||||
g.mu.Unlock()
|
||||
if !g.IsStale() {
|
||||
t.Error("old graph should be stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraph_TTLIsReasonable(t *testing.T) {
|
||||
// TTL must be long enough to avoid rebuild storms on busy meshes,
|
||||
// but short enough to reflect topology changes within minutes.
|
||||
if neighborGraphTTL < 1*time.Minute {
|
||||
t.Errorf("neighborGraphTTL too short (%v), will cause rebuild storms", neighborGraphTTL)
|
||||
}
|
||||
if neighborGraphTTL > 10*time.Minute {
|
||||
t.Errorf("neighborGraphTTL too long (%v), topology changes will be stale", neighborGraphTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedToLower(t *testing.T) {
|
||||
cache := make(map[string]string)
|
||||
// Basic lowercasing
|
||||
if got := cachedToLower(cache, "AABB"); got != "aabb" {
|
||||
t.Errorf("expected 'aabb', got %q", got)
|
||||
}
|
||||
// Verify it was cached
|
||||
if _, ok := cache["AABB"]; !ok {
|
||||
t.Error("expected 'AABB' to be in cache")
|
||||
}
|
||||
// Same input returns cached result
|
||||
if got := cachedToLower(cache, "AABB"); got != "aabb" {
|
||||
t.Errorf("expected cached 'aabb', got %q", got)
|
||||
}
|
||||
// Already lowercase stays the same
|
||||
if got := cachedToLower(cache, "aabb"); got != "aabb" {
|
||||
t.Errorf("expected 'aabb', got %q", got)
|
||||
}
|
||||
// Empty string
|
||||
if got := cachedToLower(cache, ""); got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedDecoded_Caching(t *testing.T) {
|
||||
tx := &StoreTx{DecodedJSON: `{"pubKey":"abc123","name":"test"}`}
|
||||
// First call parses
|
||||
d1 := tx.ParsedDecoded()
|
||||
if d1 == nil {
|
||||
t.Fatal("expected non-nil parsed result")
|
||||
}
|
||||
if d1["pubKey"] != "abc123" {
|
||||
t.Errorf("expected pubKey=abc123, got %v", d1["pubKey"])
|
||||
}
|
||||
// Second call must return the exact same map (pointer equality proves caching)
|
||||
d2 := tx.ParsedDecoded()
|
||||
if &d1 == nil || &d2 == nil {
|
||||
t.Fatal("unexpected nil")
|
||||
}
|
||||
// Mutate d1 and verify d2 sees the mutation — proves same underlying map
|
||||
d1["_sentinel"] = true
|
||||
if d2["_sentinel"] != true {
|
||||
t.Error("expected same map instance from second call (caching broken)")
|
||||
}
|
||||
delete(d1, "_sentinel") // clean up
|
||||
}
|
||||
|
||||
func TestParsedDecoded_EmptyJSON(t *testing.T) {
|
||||
tx := &StoreTx{DecodedJSON: ""}
|
||||
d := tx.ParsedDecoded()
|
||||
if d != nil {
|
||||
t.Errorf("expected nil for empty DecodedJSON, got %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedDecoded_InvalidJSON(t *testing.T) {
|
||||
tx := &StoreTx{DecodedJSON: "not json"}
|
||||
d := tx.ParsedDecoded()
|
||||
if d != nil {
|
||||
t.Errorf("expected nil for invalid JSON, got %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFromNode_UsesCachedParse(t *testing.T) {
|
||||
tx := &StoreTx{DecodedJSON: `{"pubKey":"aabb1122"}`}
|
||||
// First call to extractFromNode should use ParsedDecoded
|
||||
from := extractFromNode(tx)
|
||||
if from != "aabb1122" {
|
||||
t.Errorf("expected aabb1122, got %q", from)
|
||||
}
|
||||
// ParsedDecoded should now be cached
|
||||
d := tx.ParsedDecoded()
|
||||
if d == nil || d["pubKey"] != "aabb1122" {
|
||||
t.Error("expected ParsedDecoded to return cached result")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBuildFromStore(b *testing.B) {
|
||||
// Simulate a dataset with many packets and repeated pubkeys
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeA"},
|
||||
{PublicKey: "bbbb2222", Name: "NodeB"},
|
||||
{PublicKey: "cccc3333", Name: "NodeC"},
|
||||
{PublicKey: "dddd4444", Name: "NodeD"},
|
||||
}
|
||||
const numPackets = 1000
|
||||
packets := make([]*StoreTx, 0, numPackets)
|
||||
for i := 0; i < numPackets; i++ {
|
||||
pt := 4 // ADVERT
|
||||
packets = append(packets, &StoreTx{
|
||||
ID: i,
|
||||
PayloadType: &pt,
|
||||
DecodedJSON: `{"pubKey":"aaaa1111"}`,
|
||||
Observations: []*StoreObs{
|
||||
{ObserverID: "bbbb2222", PathJSON: `["cccc"]`, Timestamp: nowStr, SNR: ngFloatPtr(-5.0)},
|
||||
},
|
||||
})
|
||||
}
|
||||
store := ngTestStore(nodes, packets)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
BuildFromStore(store)
|
||||
}
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// persistSem limits concurrent async persistence goroutines to 1.
|
||||
// Without this, each ingest cycle spawns a goroutine that opens a new
|
||||
// SQLite RW connection; under sustained load goroutines pile up with
|
||||
// no backpressure, causing contention and busy-timeout cascades.
|
||||
var persistSem = make(chan struct{}, 1)
|
||||
|
||||
// ─── neighbor_edges table ──────────────────────────────────────────────────────
|
||||
|
||||
// ensureNeighborEdgesTable creates the neighbor_edges table if it doesn't exist.
|
||||
// Uses a separate read-write connection since the main DB is read-only.
|
||||
func ensureNeighborEdgesTable(dbPath string) error {
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open rw for neighbor_edges: %w", err)
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
_, err = rw.Exec(`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)
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
|
||||
// loadNeighborEdgesFromDB loads all edges from the neighbor_edges table
|
||||
// and builds an in-memory NeighborGraph.
|
||||
func loadNeighborEdgesFromDB(conn *sql.DB) *NeighborGraph {
|
||||
g := NewNeighborGraph()
|
||||
|
||||
rows, err := conn.Query("SELECT node_a, node_b, count, last_seen FROM neighbor_edges")
|
||||
if err != nil {
|
||||
log.Printf("[neighbor] failed to load neighbor_edges: %v", err)
|
||||
return g
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var a, b string
|
||||
var cnt int
|
||||
var lastSeen sql.NullString
|
||||
if err := rows.Scan(&a, &b, &cnt, &lastSeen); err != nil {
|
||||
continue
|
||||
}
|
||||
ts := time.Time{}
|
||||
if lastSeen.Valid {
|
||||
ts = parseTimestamp(lastSeen.String)
|
||||
}
|
||||
// Build edge directly (both nodes are full pubkeys from persisted data)
|
||||
key := makeEdgeKey(a, b)
|
||||
g.mu.Lock()
|
||||
e, exists := g.edges[key]
|
||||
if !exists {
|
||||
e = &NeighborEdge{
|
||||
NodeA: key.A,
|
||||
NodeB: key.B,
|
||||
Observers: make(map[string]bool),
|
||||
FirstSeen: ts,
|
||||
LastSeen: ts,
|
||||
Count: cnt,
|
||||
}
|
||||
g.edges[key] = e
|
||||
g.byNode[key.A] = append(g.byNode[key.A], e)
|
||||
g.byNode[key.B] = append(g.byNode[key.B], e)
|
||||
} else {
|
||||
e.Count += cnt
|
||||
if ts.After(e.LastSeen) {
|
||||
e.LastSeen = ts
|
||||
}
|
||||
}
|
||||
g.mu.Unlock()
|
||||
count++
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
g.mu.Lock()
|
||||
g.builtAt = time.Now()
|
||||
g.mu.Unlock()
|
||||
log.Printf("[neighbor] loaded %d edges from neighbor_edges table", count)
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// ─── shared async persistence helper ───────────────────────────────────────────
|
||||
|
||||
// persistObsUpdate holds data for a resolved_path SQLite update.
|
||||
type persistObsUpdate struct {
|
||||
obsID int
|
||||
resolvedPath string
|
||||
}
|
||||
|
||||
// persistEdgeUpdate holds data for a neighbor_edges SQLite upsert.
|
||||
type persistEdgeUpdate struct {
|
||||
a, b, ts string
|
||||
}
|
||||
|
||||
// asyncPersistResolvedPathsAndEdges writes resolved_path updates and neighbor
|
||||
// edge upserts to SQLite in a background goroutine. Shared between
|
||||
// IngestNewFromDB and IngestNewObservations to avoid DRY violation.
|
||||
func asyncPersistResolvedPathsAndEdges(dbPath string, obsUpdates []persistObsUpdate, edgeUpdates []persistEdgeUpdate, logPrefix string) {
|
||||
if len(obsUpdates) == 0 && len(edgeUpdates) == 0 {
|
||||
return
|
||||
}
|
||||
// Try-acquire semaphore BEFORE spawning goroutine. If another
|
||||
// persistence operation is already running, drop this batch —
|
||||
// data lives in memory and will be backfilled on restart.
|
||||
select {
|
||||
case persistSem <- struct{}{}:
|
||||
// Acquired — spawn goroutine to do the work.
|
||||
default:
|
||||
log.Printf("[store] %s skipped: persistence already in progress", logPrefix)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer func() { <-persistSem }()
|
||||
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[store] %s rw open error: %v", logPrefix, err)
|
||||
return
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
if len(obsUpdates) > 0 {
|
||||
sqlTx, err := rw.Begin()
|
||||
if err == nil {
|
||||
stmt, err := sqlTx.Prepare("UPDATE observations SET resolved_path = ? WHERE id = ?")
|
||||
if err == nil {
|
||||
var firstErr error
|
||||
for _, u := range obsUpdates {
|
||||
if _, err := stmt.Exec(u.resolvedPath, u.obsID); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
if firstErr != nil {
|
||||
log.Printf("[store] %s resolved_path error (first): %v", logPrefix, firstErr)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[store] %s resolved_path prepare error: %v", logPrefix, err)
|
||||
}
|
||||
sqlTx.Commit()
|
||||
}
|
||||
}
|
||||
|
||||
if len(edgeUpdates) > 0 {
|
||||
sqlTx, err := rw.Begin()
|
||||
if err == nil {
|
||||
stmt, err := sqlTx.Prepare(`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 = MAX(last_seen, excluded.last_seen)`)
|
||||
if err == nil {
|
||||
var firstErr error
|
||||
for _, e := range edgeUpdates {
|
||||
if _, err := stmt.Exec(e.a, e.b, e.ts); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
if firstErr != nil {
|
||||
log.Printf("[store] %s edge error (first): %v", logPrefix, firstErr)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[store] %s edge prepare error: %v", logPrefix, err)
|
||||
}
|
||||
sqlTx.Commit()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// neighborEdgesTableExists checks if the neighbor_edges table has any data.
|
||||
func neighborEdgesTableExists(conn *sql.DB) bool {
|
||||
var cnt int
|
||||
err := conn.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&cnt)
|
||||
if err != nil {
|
||||
return false // table doesn't exist
|
||||
}
|
||||
return cnt > 0
|
||||
}
|
||||
|
||||
// buildAndPersistEdges scans all packets in the store, extracts edges per
|
||||
// ADVERT/non-ADVERT rules, and persists them to SQLite.
|
||||
func buildAndPersistEdges(store *PacketStore, rw *sql.DB) int {
|
||||
store.mu.RLock()
|
||||
packets := make([]*StoreTx, len(store.packets))
|
||||
copy(packets, store.packets)
|
||||
store.mu.RUnlock()
|
||||
|
||||
_, pm := store.getCachedNodesAndPM()
|
||||
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[neighbor] begin tx error: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`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 = MAX(last_seen, excluded.last_seen)`)
|
||||
if err != nil {
|
||||
log.Printf("[neighbor] prepare stmt error: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
edgeCount := 0
|
||||
var firstErr error
|
||||
for _, pkt := range packets {
|
||||
for _, obs := range pkt.Observations {
|
||||
for _, ec := range extractEdgesFromObs(obs, pkt, pm) {
|
||||
if _, err := stmt.Exec(ec.A, ec.B, ec.Timestamp); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
edgeCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
log.Printf("[neighbor] edge exec error (first): %v", firstErr)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[neighbor] commit error: %v", err)
|
||||
return 0
|
||||
}
|
||||
return edgeCount
|
||||
}
|
||||
|
||||
// ─── resolved_path column ──────────────────────────────────────────────────────
|
||||
|
||||
// ensureResolvedPathColumn adds the resolved_path column to observations if missing.
|
||||
func ensureResolvedPathColumn(dbPath string) error {
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
// Check if column already exists
|
||||
rows, err := rw.Query("PRAGMA table_info(observations)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "resolved_path" {
|
||||
return nil // already exists
|
||||
}
|
||||
}
|
||||
|
||||
_, err = rw.Exec("ALTER TABLE observations ADD COLUMN resolved_path TEXT")
|
||||
if err != nil {
|
||||
return fmt.Errorf("add resolved_path column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added resolved_path column to observations")
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePathForObs resolves hop prefixes to full pubkeys for an observation.
|
||||
// Returns nil if path is empty.
|
||||
func resolvePathForObs(pathJSON, observerID string, tx *StoreTx, pm *prefixMap, graph *NeighborGraph) []*string {
|
||||
hops := parsePathJSON(pathJSON)
|
||||
if len(hops) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build context pubkeys: observer + originator (if known)
|
||||
contextPKs := make([]string, 0, 3)
|
||||
if observerID != "" {
|
||||
contextPKs = append(contextPKs, strings.ToLower(observerID))
|
||||
}
|
||||
fromNode := extractFromNode(tx)
|
||||
if fromNode != "" {
|
||||
contextPKs = append(contextPKs, strings.ToLower(fromNode))
|
||||
}
|
||||
|
||||
resolved := make([]*string, len(hops))
|
||||
for i, hop := range hops {
|
||||
// Add adjacent hops as context for disambiguation
|
||||
ctx := make([]string, len(contextPKs), len(contextPKs)+2)
|
||||
copy(ctx, contextPKs)
|
||||
// Add previously resolved hops as context
|
||||
if i > 0 && resolved[i-1] != nil {
|
||||
ctx = append(ctx, *resolved[i-1])
|
||||
}
|
||||
|
||||
node, _, _ := pm.resolveWithContext(hop, ctx, graph)
|
||||
if node != nil {
|
||||
pk := strings.ToLower(node.PublicKey)
|
||||
resolved[i] = &pk
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// marshalResolvedPath converts []*string to JSON for storage.
|
||||
func marshalResolvedPath(rp []*string) string {
|
||||
if len(rp) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(rp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// unmarshalResolvedPath parses a resolved_path JSON string.
|
||||
func unmarshalResolvedPath(s string) []*string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var result []*string
|
||||
if json.Unmarshal([]byte(s), &result) != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// backfillResolvedPaths resolves paths for all observations that have NULL resolved_path.
|
||||
func backfillResolvedPaths(store *PacketStore, dbPath string) int {
|
||||
// Collect pending observations and snapshot immutable fields under read lock.
|
||||
// graph is set in main.go before backfill is called; nil-safe throughout (review item #6).
|
||||
type obsRef struct {
|
||||
obsID int
|
||||
pathJSON string
|
||||
observerID string
|
||||
txJSON string // snapshot of DecodedJSON for extractFromNode
|
||||
payloadType *int
|
||||
}
|
||||
store.mu.RLock()
|
||||
pm := store.nodePM
|
||||
graph := store.graph
|
||||
var pending []obsRef
|
||||
for _, tx := range store.packets {
|
||||
for _, obs := range tx.Observations {
|
||||
if obs.ResolvedPath == nil && obs.PathJSON != "" && obs.PathJSON != "[]" {
|
||||
pending = append(pending, obsRef{
|
||||
obsID: obs.ID,
|
||||
pathJSON: obs.PathJSON,
|
||||
observerID: obs.ObserverID,
|
||||
txJSON: tx.DecodedJSON,
|
||||
payloadType: tx.PayloadType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
store.mu.RUnlock()
|
||||
|
||||
if len(pending) == 0 || pm == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Resolve paths outside the lock — resolvePathForObs only reads pm and graph.
|
||||
type resolved struct {
|
||||
obsID int
|
||||
rp []*string
|
||||
rpJSON string
|
||||
}
|
||||
var results []resolved
|
||||
for _, ref := range pending {
|
||||
// Build a minimal StoreTx for extractFromNode (only needs DecodedJSON + PayloadType).
|
||||
fakeTx := &StoreTx{DecodedJSON: ref.txJSON, PayloadType: ref.payloadType}
|
||||
rp := resolvePathForObs(ref.pathJSON, ref.observerID, fakeTx, pm, graph)
|
||||
if len(rp) > 0 {
|
||||
rpJSON := marshalResolvedPath(rp)
|
||||
if rpJSON != "" {
|
||||
results = append(results, resolved{ref.obsID, rp, rpJSON})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Persist to SQLite (no lock needed — separate RW connection).
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[store] backfill: open rw error: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
sqlTx, err := rw.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[store] backfill: begin tx error: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer sqlTx.Rollback()
|
||||
|
||||
stmt, err := sqlTx.Prepare("UPDATE observations SET resolved_path = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
log.Printf("[store] backfill: prepare error: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
var firstErr error
|
||||
for _, r := range results {
|
||||
if _, err := stmt.Exec(r.rpJSON, r.obsID); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
log.Printf("[store] backfill resolved_path exec error (first): %v", firstErr)
|
||||
}
|
||||
|
||||
if err := sqlTx.Commit(); err != nil {
|
||||
log.Printf("[store] backfill: commit error: %v", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Update in-memory state under write lock.
|
||||
store.mu.Lock()
|
||||
count := 0
|
||||
for _, r := range results {
|
||||
if obs, ok := store.byObsID[r.obsID]; ok {
|
||||
obs.ResolvedPath = r.rp
|
||||
count++
|
||||
}
|
||||
}
|
||||
store.mu.Unlock()
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// ─── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// edgeCandidate represents an extracted edge to be persisted.
|
||||
type edgeCandidate struct {
|
||||
A, B, Timestamp string
|
||||
}
|
||||
|
||||
// extractEdgesFromObs extracts neighbor edge candidates from a single observation.
|
||||
// For ADVERTs: originator↔path[0] (if unambiguous). For ALL types: observer↔path[last] (if unambiguous).
|
||||
// Also handles zero-hop ADVERTs (originator↔observer direct link).
|
||||
func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandidate {
|
||||
isAdvert := tx.PayloadType != nil && *tx.PayloadType == 4
|
||||
fromNode := extractFromNode(tx)
|
||||
path := parsePathJSON(obs.PathJSON)
|
||||
observerPK := strings.ToLower(obs.ObserverID)
|
||||
ts := obs.Timestamp
|
||||
var edges []edgeCandidate
|
||||
|
||||
if len(path) == 0 {
|
||||
if isAdvert && fromNode != "" {
|
||||
fromLower := strings.ToLower(fromNode)
|
||||
if fromLower != observerPK {
|
||||
a, b := fromLower, observerPK
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
edges = append(edges, edgeCandidate{a, b, ts})
|
||||
}
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
// Edge 1: originator ↔ path[0] — ADVERTs only (resolve prefix to full pubkey)
|
||||
if isAdvert && fromNode != "" && pm != nil {
|
||||
firstHop := strings.ToLower(path[0])
|
||||
fromLower := strings.ToLower(fromNode)
|
||||
candidates := pm.m[firstHop]
|
||||
if len(candidates) == 1 {
|
||||
resolved := strings.ToLower(candidates[0].PublicKey)
|
||||
if resolved != fromLower {
|
||||
a, b := fromLower, resolved
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
edges = append(edges, edgeCandidate{a, b, ts})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edge 2: observer ↔ path[last] — ALL packet types
|
||||
if pm != nil {
|
||||
lastHop := strings.ToLower(path[len(path)-1])
|
||||
candidates := pm.m[lastHop]
|
||||
if len(candidates) == 1 {
|
||||
resolved := strings.ToLower(candidates[0].PublicKey)
|
||||
if resolved != observerPK {
|
||||
a, b := observerPK, resolved
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
edges = append(edges, edgeCandidate{a, b, ts})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
// openRW opens a read-write SQLite connection (same pattern as PruneOldPackets).
|
||||
func openRW(dbPath string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=10000", dbPath)
|
||||
rw, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rw.SetMaxOpenConns(1)
|
||||
return rw, nil
|
||||
}
|
||||
@@ -1,534 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// createTestDBWithSchema creates a temp SQLite DB with the standard schema + resolved_path column.
|
||||
func createTestDBWithSchema(t *testing.T) (*DB, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
conn, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create tables
|
||||
conn.Exec(`CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
|
||||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
|
||||
decoded_json TEXT
|
||||
)`)
|
||||
conn.Exec(`CREATE TABLE observers (
|
||||
id TEXT PRIMARY KEY, name TEXT, iata TEXT
|
||||
)`)
|
||||
conn.Exec(`CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
observer_id TEXT, observer_name TEXT, direction TEXT,
|
||||
snr REAL, rssi REAL, score INTEGER,
|
||||
path_json TEXT, timestamp TEXT,
|
||||
resolved_path TEXT
|
||||
)`)
|
||||
conn.Exec(`CREATE TABLE nodes (
|
||||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
|
||||
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0
|
||||
)`)
|
||||
|
||||
conn.Close()
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db, dbPath
|
||||
}
|
||||
|
||||
func TestResolvePathForObs(t *testing.T) {
|
||||
// Build a prefix map with known nodes
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
|
||||
{PublicKey: "bbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb11", Name: "Node-BB"},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
graph := NewNeighborGraph()
|
||||
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey": "originator1234567890"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
|
||||
// Unambiguous prefixes should resolve
|
||||
rp := resolvePathForObs(`["aa","bb"]`, "observer1", tx, pm, graph)
|
||||
if len(rp) != 2 {
|
||||
t.Fatalf("expected 2 resolved hops, got %d", len(rp))
|
||||
}
|
||||
if rp[0] == nil || !strings.HasPrefix(*rp[0], "aabbcc") {
|
||||
t.Errorf("expected first hop to resolve to Node-AA, got %v", rp[0])
|
||||
}
|
||||
if rp[1] == nil || !strings.HasPrefix(*rp[1], "bbccdd") {
|
||||
t.Errorf("expected second hop to resolve to Node-BB, got %v", rp[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePathForObs_EmptyPath(t *testing.T) {
|
||||
pm := buildPrefixMap(nil)
|
||||
rp := resolvePathForObs(`[]`, "", &StoreTx{}, pm, nil)
|
||||
if rp != nil {
|
||||
t.Errorf("expected nil for empty path, got %v", rp)
|
||||
}
|
||||
|
||||
rp = resolvePathForObs("", "", &StoreTx{}, pm, nil)
|
||||
if rp != nil {
|
||||
t.Errorf("expected nil for empty string, got %v", rp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePathForObs_Unresolvable(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
// "zz" prefix doesn't match any node
|
||||
rp := resolvePathForObs(`["zz"]`, "", &StoreTx{}, pm, nil)
|
||||
if len(rp) != 1 {
|
||||
t.Fatalf("expected 1 hop, got %d", len(rp))
|
||||
}
|
||||
if rp[0] != nil {
|
||||
t.Errorf("expected nil for unresolvable hop, got %v", *rp[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalUnmarshalResolvedPath(t *testing.T) {
|
||||
pk1 := "aabbccdd"
|
||||
var rp []*string
|
||||
rp = append(rp, &pk1, nil)
|
||||
|
||||
j := marshalResolvedPath(rp)
|
||||
if j == "" {
|
||||
t.Fatal("expected non-empty JSON")
|
||||
}
|
||||
|
||||
parsed := unmarshalResolvedPath(j)
|
||||
if len(parsed) != 2 {
|
||||
t.Fatalf("expected 2 elements, got %d", len(parsed))
|
||||
}
|
||||
if parsed[0] == nil || *parsed[0] != "aabbccdd" {
|
||||
t.Errorf("first element wrong: %v", parsed[0])
|
||||
}
|
||||
if parsed[1] != nil {
|
||||
t.Errorf("second element should be nil, got %v", *parsed[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalResolvedPath_Empty(t *testing.T) {
|
||||
if marshalResolvedPath(nil) != "" {
|
||||
t.Error("expected empty for nil")
|
||||
}
|
||||
if marshalResolvedPath([]*string{}) != "" {
|
||||
t.Error("expected empty for empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalResolvedPath_Invalid(t *testing.T) {
|
||||
if unmarshalResolvedPath("") != nil {
|
||||
t.Error("expected nil for empty string")
|
||||
}
|
||||
if unmarshalResolvedPath("not json") != nil {
|
||||
t.Error("expected nil for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNeighborEdgesTable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create initial DB
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY)")
|
||||
conn.Close()
|
||||
|
||||
if err := ensureNeighborEdgesTable(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify table exists
|
||||
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
|
||||
defer conn.Close()
|
||||
var cnt int
|
||||
if err := conn.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&cnt); err != nil {
|
||||
t.Fatalf("neighbor_edges table not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNeighborEdgesFromDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec(`CREATE TABLE 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)
|
||||
)`)
|
||||
conn.Exec("INSERT INTO neighbor_edges VALUES ('aaa', 'bbb', 5, '2024-01-01T00:00:00Z')")
|
||||
conn.Exec("INSERT INTO neighbor_edges VALUES ('ccc', 'ddd', 3, '2024-01-02T00:00:00Z')")
|
||||
|
||||
g := loadNeighborEdgesFromDB(conn)
|
||||
conn.Close()
|
||||
|
||||
// Should have 2 edges
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 2 {
|
||||
t.Errorf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
|
||||
// Check neighbors
|
||||
n := g.Neighbors("aaa")
|
||||
if len(n) != 1 {
|
||||
t.Errorf("expected 1 neighbor for aaa, got %d", len(n))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreObsResolvedPathInBroadcast(t *testing.T) {
|
||||
// Verify resolved_path appears in broadcast maps
|
||||
pk := "aabbccdd"
|
||||
obs := &StoreObs{
|
||||
ID: 1,
|
||||
ObserverID: "obs1",
|
||||
ObserverName: "Observer 1",
|
||||
PathJSON: `["aa"]`,
|
||||
ResolvedPath: []*string{&pk},
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc123",
|
||||
Observations: []*StoreObs{obs},
|
||||
}
|
||||
pickBestObservation(tx)
|
||||
|
||||
if tx.ResolvedPath == nil {
|
||||
t.Fatal("expected ResolvedPath to be set on tx after pickBestObservation")
|
||||
}
|
||||
if *tx.ResolvedPath[0] != "aabbccdd" {
|
||||
t.Errorf("expected resolved path to be aabbccdd, got %s", *tx.ResolvedPath[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathInTxToMap(t *testing.T) {
|
||||
pk := "aabbccdd"
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc123",
|
||||
PathJSON: `["aa"]`,
|
||||
ResolvedPath: []*string{&pk},
|
||||
obsKeys: make(map[string]bool),
|
||||
}
|
||||
|
||||
m := txToMap(tx)
|
||||
rp, ok := m["resolved_path"]
|
||||
if !ok {
|
||||
t.Fatal("resolved_path not in txToMap output")
|
||||
}
|
||||
rpSlice, ok := rp.([]*string)
|
||||
if !ok || len(rpSlice) != 1 || *rpSlice[0] != "aabbccdd" {
|
||||
t.Errorf("unexpected resolved_path: %v", rp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathOmittedWhenNil(t *testing.T) {
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc123",
|
||||
obsKeys: make(map[string]bool),
|
||||
}
|
||||
|
||||
m := txToMap(tx)
|
||||
if _, ok := m["resolved_path"]; ok {
|
||||
t.Error("resolved_path should not be in map when nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureResolvedPathColumn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec(`CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY, transmission_id INTEGER,
|
||||
observer_id TEXT, path_json TEXT, timestamp TEXT
|
||||
)`)
|
||||
conn.Close()
|
||||
|
||||
if err := ensureResolvedPathColumn(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify column exists
|
||||
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
|
||||
defer conn.Close()
|
||||
rows, _ := conn.Query("PRAGMA table_info(observations)")
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk)
|
||||
if colName == "resolved_path" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if !found {
|
||||
t.Error("resolved_path column not added")
|
||||
}
|
||||
|
||||
// Running again should be idempotent
|
||||
if err := ensureResolvedPathColumn(dbPath); err != nil {
|
||||
t.Fatal("second call should be idempotent:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBDetectsResolvedPathColumn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create DB without resolved_path
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY, observer_idx INTEGER)`)
|
||||
conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY)`)
|
||||
conn.Close()
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db.hasResolvedPath {
|
||||
t.Error("should not detect resolved_path when column missing")
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Add resolved_path column
|
||||
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec("ALTER TABLE observations ADD COLUMN resolved_path TEXT")
|
||||
conn.Close()
|
||||
|
||||
db, err = OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.hasResolvedPath {
|
||||
t.Error("should detect resolved_path when column exists")
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
|
||||
func TestLoadWithResolvedPath(t *testing.T) {
|
||||
db, dbPath := createTestDBWithSchema(t)
|
||||
defer db.Close()
|
||||
|
||||
// Insert test data
|
||||
rw, _ := openRW(dbPath)
|
||||
rw.Exec(`INSERT INTO transmissions (id, hash, first_seen, payload_type, decoded_json)
|
||||
VALUES (1, 'hash1', '2024-01-01T00:00:00Z', 4, '{"pubKey":"origpk"}')`)
|
||||
rw.Exec(`INSERT INTO observations (id, transmission_id, observer_id, observer_name, path_json, timestamp, resolved_path)
|
||||
VALUES (1, 1, 'obs1', 'Observer1', '["aa"]', '2024-01-01T00:00:00Z', '["aabbccdd"]')`)
|
||||
rw.Close()
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(store.packets) != 1 {
|
||||
t.Fatalf("expected 1 packet, got %d", len(store.packets))
|
||||
}
|
||||
|
||||
tx := store.packets[0]
|
||||
if len(tx.Observations) != 1 {
|
||||
t.Fatalf("expected 1 observation, got %d", len(tx.Observations))
|
||||
}
|
||||
|
||||
obs := tx.Observations[0]
|
||||
if obs.ResolvedPath == nil {
|
||||
t.Fatal("expected ResolvedPath to be loaded")
|
||||
}
|
||||
if len(obs.ResolvedPath) != 1 || *obs.ResolvedPath[0] != "aabbccdd" {
|
||||
t.Errorf("unexpected ResolvedPath: %v", obs.ResolvedPath)
|
||||
}
|
||||
|
||||
// Check that pickBestObservation propagated resolved_path to tx
|
||||
if tx.ResolvedPath == nil || len(tx.ResolvedPath) != 1 {
|
||||
t.Error("expected ResolvedPath to be propagated to tx")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathInAPIResponse(t *testing.T) {
|
||||
// Test that TransmissionResp properly marshals resolved_path
|
||||
pk := "aabbccddee"
|
||||
resp := TransmissionResp{
|
||||
ID: 1,
|
||||
Hash: "test",
|
||||
ResolvedPath: []*string{&pk, nil},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal(data, &m)
|
||||
|
||||
rp, ok := m["resolved_path"]
|
||||
if !ok {
|
||||
t.Fatal("resolved_path missing from JSON")
|
||||
}
|
||||
rpArr, ok := rp.([]interface{})
|
||||
if !ok || len(rpArr) != 2 {
|
||||
t.Fatalf("unexpected resolved_path shape: %v", rp)
|
||||
}
|
||||
if rpArr[0] != "aabbccddee" {
|
||||
t.Errorf("first element wrong: %v", rpArr[0])
|
||||
}
|
||||
if rpArr[1] != nil {
|
||||
t.Errorf("second element should be null: %v", rpArr[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathOmittedWhenEmpty(t *testing.T) {
|
||||
resp := TransmissionResp{
|
||||
ID: 1,
|
||||
Hash: "test",
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(resp)
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal(data, &m)
|
||||
|
||||
if _, ok := m["resolved_path"]; ok {
|
||||
t.Error("resolved_path should be omitted when nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_AdvertNoPath(t *testing.T) {
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey":"aaaa1111"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
obs := &StoreObs{
|
||||
ObserverID: "bbbb2222",
|
||||
PathJSON: "",
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
edges := extractEdgesFromObs(obs, tx, nil)
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge for zero-hop advert, got %d", len(edges))
|
||||
}
|
||||
// Canonical ordering: aaaa < bbbb
|
||||
if edges[0].A != "aaaa1111" || edges[0].B != "bbbb2222" {
|
||||
t.Errorf("unexpected edge: %+v", edges[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_NonAdvertNoPath(t *testing.T) {
|
||||
tx := &StoreTx{PayloadType: intPtr(1)}
|
||||
obs := &StoreObs{ObserverID: "obs1", PathJSON: ""}
|
||||
edges := extractEdgesFromObs(obs, tx, nil)
|
||||
if len(edges) != 0 {
|
||||
t.Errorf("expected 0 edges for non-advert without path, got %d", len(edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_WithPath(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
|
||||
{PublicKey: "ffgghhii1234567890aabbccddee1234567890aabbccddee1234567890aabb11", Name: "Node-FF"},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey":"originator00"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
obs := &StoreObs{
|
||||
ObserverID: "observer00",
|
||||
PathJSON: `["aa","ff"]`,
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
edges := extractEdgesFromObs(obs, tx, pm)
|
||||
// Should get: originator↔aa (advert), observer↔ff (last hop)
|
||||
if len(edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_SameNodeNoEdge(t *testing.T) {
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey":"same1234"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
obs := &StoreObs{
|
||||
ObserverID: "same1234",
|
||||
PathJSON: "",
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
edges := extractEdgesFromObs(obs, tx, nil)
|
||||
if len(edges) != 0 {
|
||||
t.Errorf("expected 0 edges when originator == observer, got %d", len(edges))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func TestPersistSemaphoreTryAcquireSkipsBatch(t *testing.T) {
|
||||
// Verify that persistSem is a buffered channel of size 1.
|
||||
if cap(persistSem) != 1 {
|
||||
t.Errorf("persistSem capacity = %d, want 1", cap(persistSem))
|
||||
}
|
||||
// Acquire the semaphore to simulate an in-progress persistence.
|
||||
persistSem <- struct{}{}
|
||||
|
||||
// asyncPersistResolvedPathsAndEdges should skip (not block, not
|
||||
// spawn a goroutine) when the semaphore is already held.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
asyncPersistResolvedPathsAndEdges(
|
||||
"/nonexistent/path.db",
|
||||
[]persistObsUpdate{{obsID: 1, resolvedPath: "x"}},
|
||||
nil,
|
||||
"test",
|
||||
)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// If the function blocks on the semaphore instead of skipping,
|
||||
// this select will hit the timeout.
|
||||
select {
|
||||
case <-done:
|
||||
// Expected: returned immediately because semaphore was busy.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
<-persistSem
|
||||
t.Fatal("asyncPersistResolvedPathsAndEdges blocked instead of skipping when semaphore was held")
|
||||
}
|
||||
|
||||
<-persistSem // release
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── resolveWithContext unit tests ─────────────────────────────────────────────
|
||||
|
||||
func TestResolveWithContext_UniquePrefix(t *testing.T) {
|
||||
pm := buildPrefixMap([]nodeInfo{
|
||||
{PublicKey: "a1b2c3d4", Name: "Node-A", HasGPS: true, Lat: 1, Lon: 2},
|
||||
})
|
||||
ni, confidence, _ := pm.resolveWithContext("a1b2c3d4", nil, nil)
|
||||
if ni == nil || ni.Name != "Node-A" {
|
||||
t.Fatal("expected Node-A")
|
||||
}
|
||||
if confidence != "unique_prefix" {
|
||||
t.Fatalf("expected unique_prefix, got %s", confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_NoMatch(t *testing.T) {
|
||||
pm := buildPrefixMap([]nodeInfo{
|
||||
{PublicKey: "a1b2c3d4", Name: "Node-A"},
|
||||
})
|
||||
ni, confidence, _ := pm.resolveWithContext("ff", nil, nil)
|
||||
if ni != nil {
|
||||
t.Fatal("expected nil")
|
||||
}
|
||||
if confidence != "no_match" {
|
||||
t.Fatalf("expected no_match, got %s", confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_AffinityWins(t *testing.T) {
|
||||
pm := buildPrefixMap([]nodeInfo{
|
||||
{PublicKey: "a1aaaaaa", Name: "Node-A1"},
|
||||
{PublicKey: "a1bbbbbb", Name: "Node-A2"},
|
||||
})
|
||||
|
||||
graph := NewNeighborGraph()
|
||||
for i := 0; i < 100; i++ {
|
||||
graph.upsertEdge("c0c0c0c0", "a1aaaaaa", "a1", "obs1", nil, time.Now())
|
||||
}
|
||||
|
||||
ni, confidence, score := pm.resolveWithContext("a1", []string{"c0c0c0c0"}, graph)
|
||||
if ni == nil || ni.Name != "Node-A1" {
|
||||
t.Fatalf("expected Node-A1, got %v", ni)
|
||||
}
|
||||
if confidence != "neighbor_affinity" {
|
||||
t.Fatalf("expected neighbor_affinity, got %s", confidence)
|
||||
}
|
||||
if score <= 0 {
|
||||
t.Fatalf("expected positive score, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_AffinityTooClose_FallsToGeo(t *testing.T) {
|
||||
pm := buildPrefixMap([]nodeInfo{
|
||||
{PublicKey: "a1aaaaaa", Name: "Node-A1", HasGPS: true, Lat: 10, Lon: 20},
|
||||
{PublicKey: "a1bbbbbb", Name: "Node-A2", HasGPS: true, Lat: 11, Lon: 21},
|
||||
{PublicKey: "c0c0c0c0", Name: "Ctx", HasGPS: true, Lat: 10.1, Lon: 20.1},
|
||||
})
|
||||
|
||||
graph := NewNeighborGraph()
|
||||
for i := 0; i < 50; i++ {
|
||||
graph.upsertEdge("c0c0c0c0", "a1aaaaaa", "a1", "obs1", nil, time.Now())
|
||||
graph.upsertEdge("c0c0c0c0", "a1bbbbbb", "a1", "obs1", nil, time.Now())
|
||||
}
|
||||
|
||||
ni, confidence, _ := pm.resolveWithContext("a1", []string{"c0c0c0c0"}, graph)
|
||||
if ni == nil {
|
||||
t.Fatal("expected a result")
|
||||
}
|
||||
if confidence != "geo_proximity" {
|
||||
t.Fatalf("expected geo_proximity, got %s", confidence)
|
||||
}
|
||||
if ni.Name != "Node-A1" {
|
||||
t.Fatalf("expected Node-A1 (closer to context), got %s", ni.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_GPSPreference(t *testing.T) {
|
||||
pm := buildPrefixMap([]nodeInfo{
|
||||
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
||||
{PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
||||
})
|
||||
|
||||
ni, confidence, _ := pm.resolveWithContext("a1", nil, nil)
|
||||
if ni == nil || ni.Name != "HasGPS" {
|
||||
t.Fatalf("expected HasGPS, got %v", ni)
|
||||
}
|
||||
if confidence != "gps_preference" {
|
||||
t.Fatalf("expected gps_preference, got %s", confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_FirstMatchFallback(t *testing.T) {
|
||||
pm := buildPrefixMap([]nodeInfo{
|
||||
{PublicKey: "a1aaaaaa", Name: "First"},
|
||||
{PublicKey: "a1bbbbbb", Name: "Second"},
|
||||
})
|
||||
|
||||
ni, confidence, _ := pm.resolveWithContext("a1", nil, nil)
|
||||
if ni == nil || ni.Name != "First" {
|
||||
t.Fatalf("expected First, got %v", ni)
|
||||
}
|
||||
if confidence != "first_match" {
|
||||
t.Fatalf("expected first_match, got %s", confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_NilGraphFallsToGPS(t *testing.T) {
|
||||
pm := buildPrefixMap([]nodeInfo{
|
||||
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
||||
{PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
||||
})
|
||||
|
||||
ni, confidence, _ := pm.resolveWithContext("a1", []string{"someone"}, nil)
|
||||
if ni == nil || ni.Name != "HasGPS" {
|
||||
t.Fatalf("expected HasGPS, got %v", ni)
|
||||
}
|
||||
if confidence != "gps_preference" {
|
||||
t.Fatalf("expected gps_preference, got %s", confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_BackwardCompatResolve(t *testing.T) {
|
||||
// Verify original resolve() still works unchanged
|
||||
pm := buildPrefixMap([]nodeInfo{
|
||||
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
||||
{PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
||||
})
|
||||
ni := pm.resolve("a1")
|
||||
if ni == nil || ni.Name != "HasGPS" {
|
||||
t.Fatalf("expected HasGPS from resolve(), got %v", ni)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── geoDistApprox ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGeoDistApprox_SamePoint(t *testing.T) {
|
||||
d := geoDistApprox(37.0, -122.0, 37.0, -122.0)
|
||||
if d != 0 {
|
||||
t.Fatalf("expected 0, got %f", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoDistApprox_Ordering(t *testing.T) {
|
||||
d1 := geoDistApprox(37.0, -122.0, 37.01, -122.01)
|
||||
d2 := geoDistApprox(37.0, -122.0, 38.0, -121.0)
|
||||
if d1 >= d2 {
|
||||
t.Fatal("closer point should have smaller distance")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── handleResolveHops enhanced response (API tests) ───────────────────────────
|
||||
|
||||
func TestResolveHopsAPI_UniquePrefix(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
_ = srv
|
||||
|
||||
// Insert a unique node
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"ff11223344", "UniqueNode", 37.0, -122.0)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ff11223344", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
var result ResolveHopsResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("bad JSON: %v", err)
|
||||
}
|
||||
|
||||
hr, ok := result.Resolved["ff11223344"]
|
||||
if !ok {
|
||||
t.Fatal("expected hop in resolved map")
|
||||
}
|
||||
if hr.Confidence != "unique_prefix" {
|
||||
t.Fatalf("expected unique_prefix, got %s", hr.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHopsAPI_AmbiguousNoContext(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"ee1aaaaaaa", "Node-E1", 37.0, -122.0)
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"ee1bbbbbbb", "Node-E2", 38.0, -121.0)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ee1", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
var result ResolveHopsResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &result)
|
||||
|
||||
hr := result.Resolved["ee1"]
|
||||
if hr == nil {
|
||||
t.Fatal("expected hop in resolved map")
|
||||
}
|
||||
if hr.Confidence != "ambiguous" {
|
||||
t.Fatalf("expected ambiguous, got %s", hr.Confidence)
|
||||
}
|
||||
if len(hr.Candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(hr.Candidates))
|
||||
}
|
||||
for _, c := range hr.Candidates {
|
||||
if c.AffinityScore != nil {
|
||||
t.Fatal("expected nil affinity score without context")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHopsAPI_WithAffinityContext(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"dd1aaaaaaa", "Node-D1", 37.0, -122.0)
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"dd1bbbbbbb", "Node-D2", 38.0, -121.0)
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"c0c0c0c0c0", "Context", 37.1, -122.1)
|
||||
|
||||
// Invalidate node cache so the PM includes newly inserted nodes.
|
||||
srv.store.cacheMu.Lock()
|
||||
srv.store.nodeCacheTime = time.Time{}
|
||||
srv.store.cacheMu.Unlock()
|
||||
|
||||
// Build graph with strong affinity
|
||||
graph := NewNeighborGraph()
|
||||
for i := 0; i < 100; i++ {
|
||||
graph.upsertEdge("c0c0c0c0c0", "dd1aaaaaaa", "dd1", "obs1", nil, time.Now())
|
||||
}
|
||||
graph.builtAt = time.Now()
|
||||
srv.neighborMu.Lock()
|
||||
srv.neighborGraph = graph
|
||||
srv.neighborMu.Unlock()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=dd1&from_node=c0c0c0c0c0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
var result ResolveHopsResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &result)
|
||||
|
||||
hr := result.Resolved["dd1"]
|
||||
if hr == nil {
|
||||
t.Fatal("expected hop in resolved map")
|
||||
}
|
||||
if hr.Confidence != "neighbor_affinity" {
|
||||
t.Fatalf("expected neighbor_affinity, got %s", hr.Confidence)
|
||||
}
|
||||
if hr.BestCandidate == nil || *hr.BestCandidate != "dd1aaaaaaa" {
|
||||
t.Fatalf("expected bestCandidate dd1aaaaaaa, got %v", hr.BestCandidate)
|
||||
}
|
||||
|
||||
// Verify affinity scores present
|
||||
hasScore := false
|
||||
for _, c := range hr.Candidates {
|
||||
if c.AffinityScore != nil && *c.AffinityScore > 0 {
|
||||
hasScore = true
|
||||
}
|
||||
}
|
||||
if !hasScore {
|
||||
t.Fatal("expected at least one candidate with affinity score")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHopsAPI_ResponseShape(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"bb1aaaaaaa", "Node-B1", 37.0, -122.0)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=bb1a", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
json.Unmarshal(rr.Body.Bytes(), &raw)
|
||||
|
||||
if _, ok := raw["resolved"]; !ok {
|
||||
t.Fatal("missing 'resolved' key")
|
||||
}
|
||||
|
||||
var resolved map[string]map[string]interface{}
|
||||
json.Unmarshal(raw["resolved"], &resolved)
|
||||
|
||||
for _, hr := range resolved {
|
||||
if _, ok := hr["confidence"]; !ok {
|
||||
t.Error("missing 'confidence' field in HopResolution")
|
||||
}
|
||||
if _, ok := hr["candidates"]; !ok {
|
||||
t.Error("missing 'candidates' field")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers used only in this test file ───────────────────────────────────────
|
||||
+18
-175
@@ -38,10 +38,6 @@ type Server struct {
|
||||
statsMu sync.Mutex
|
||||
statsCache *StatsResponse
|
||||
statsCachedAt time.Time
|
||||
|
||||
// Neighbor affinity graph (lazy-built, cached with TTL)
|
||||
neighborMu sync.Mutex
|
||||
neighborGraph *NeighborGraph
|
||||
}
|
||||
|
||||
// PerfStats tracks request performance.
|
||||
@@ -115,7 +111,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
|
||||
r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
|
||||
r.Handle("/api/admin/prune", s.requireAPIKey(http.HandlerFunc(s.handleAdminPrune))).Methods("POST")
|
||||
r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET")
|
||||
|
||||
// Packet endpoints
|
||||
r.HandleFunc("/api/packets/timestamps", s.handlePacketTimestamps).Methods("GET")
|
||||
@@ -133,7 +128,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/nodes/{pubkey}/health", s.handleNodeHealth).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/paths", s.handleNodePaths).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/analytics", s.handleNodeAnalytics).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/neighbors", s.handleNodeNeighbors).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET")
|
||||
r.HandleFunc("/api/nodes", s.handleNodes).Methods("GET")
|
||||
|
||||
@@ -146,7 +140,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/analytics/hash-collisions", s.handleAnalyticsHashCollisions).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/subpaths", s.handleAnalyticsSubpaths).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/subpath-detail", s.handleAnalyticsSubpathDetail).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/neighbor-graph", s.handleNeighborGraph).Methods("GET")
|
||||
|
||||
// Other endpoints
|
||||
r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET")
|
||||
@@ -253,7 +246,6 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
|
||||
ExternalUrls: s.cfg.ExternalUrls,
|
||||
PropagationBufferMs: float64(s.cfg.PropagationBufferMs()),
|
||||
Timestamps: s.cfg.GetTimestampConfig(),
|
||||
DebugAffinity: s.cfg.DebugAffinity,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -284,26 +276,6 @@ func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) {
|
||||
"accentHover": "#6db3ff",
|
||||
"navBg": "#0f0f23",
|
||||
"navBg2": "#1a1a2e",
|
||||
"navText": "#ffffff",
|
||||
"navTextMuted": "#cbd5e1",
|
||||
"background": "#f4f5f7",
|
||||
"text": "#1a1a2e",
|
||||
"textMuted": "#5b6370",
|
||||
"border": "#e2e5ea",
|
||||
"surface1": "#ffffff",
|
||||
"surface2": "#ffffff",
|
||||
"surface3": "#ffffff",
|
||||
"sectionBg": "#eef2ff",
|
||||
"cardBg": "#ffffff",
|
||||
"contentBg": "#f4f5f7",
|
||||
"detailBg": "#ffffff",
|
||||
"inputBg": "#ffffff",
|
||||
"rowStripe": "#f9fafb",
|
||||
"rowHover": "#eef2ff",
|
||||
"selectedBg": "#dbeafe",
|
||||
"statusGreen": "#22c55e",
|
||||
"statusYellow": "#eab308",
|
||||
"statusRed": "#ef4444",
|
||||
}, s.cfg.Theme, theme.Theme)
|
||||
|
||||
nodeColors := mergeMap(map[string]interface{}{
|
||||
@@ -314,60 +286,15 @@ func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) {
|
||||
"observer": "#8b5cf6",
|
||||
}, s.cfg.NodeColors, theme.NodeColors)
|
||||
|
||||
themeDark := mergeMap(map[string]interface{}{
|
||||
"accent": "#4a9eff",
|
||||
"accentHover": "#6db3ff",
|
||||
"navBg": "#0f0f23",
|
||||
"navBg2": "#1a1a2e",
|
||||
"navText": "#ffffff",
|
||||
"navTextMuted": "#cbd5e1",
|
||||
"background": "#0f0f23",
|
||||
"text": "#e2e8f0",
|
||||
"textMuted": "#a8b8cc",
|
||||
"border": "#334155",
|
||||
"surface1": "#1a1a2e",
|
||||
"surface2": "#232340",
|
||||
"cardBg": "#1a1a2e",
|
||||
"contentBg": "#0f0f23",
|
||||
"detailBg": "#232340",
|
||||
"inputBg": "#1e1e34",
|
||||
"rowStripe": "#1e1e34",
|
||||
"rowHover": "#2d2d50",
|
||||
"selectedBg": "#1e3a5f",
|
||||
"statusGreen": "#22c55e",
|
||||
"statusYellow": "#eab308",
|
||||
"statusRed": "#ef4444",
|
||||
"surface3": "#2d2d50",
|
||||
"sectionBg": "#1e1e34",
|
||||
}, s.cfg.ThemeDark, theme.ThemeDark)
|
||||
typeColors := mergeMap(map[string]interface{}{
|
||||
"ADVERT": "#22c55e",
|
||||
"GRP_TXT": "#3b82f6",
|
||||
"TXT_MSG": "#f59e0b",
|
||||
"ACK": "#6b7280",
|
||||
"REQUEST": "#a855f7",
|
||||
"RESPONSE": "#06b6d4",
|
||||
"TRACE": "#ec4899",
|
||||
"PATH": "#14b8a6",
|
||||
"ANON_REQ": "#f43f5e",
|
||||
"UNKNOWN": "#6b7280",
|
||||
}, s.cfg.TypeColors, theme.TypeColors)
|
||||
themeDark := mergeMap(map[string]interface{}{}, s.cfg.ThemeDark, theme.ThemeDark)
|
||||
typeColors := mergeMap(map[string]interface{}{}, s.cfg.TypeColors, theme.TypeColors)
|
||||
|
||||
defaultHome := map[string]interface{}{
|
||||
"heroTitle": "CoreScope",
|
||||
"heroSubtitle": "Real-time MeshCore LoRa mesh network analyzer",
|
||||
"steps": []interface{}{
|
||||
map[string]interface{}{"emoji": "🔵", "title": "Connect via Bluetooth", "description": "Flash **BLE companion** firmware from [MeshCore Flasher](https://flasher.meshcore.co.uk/).\n- Screenless devices: default PIN `123456`\n- Screen devices: random PIN shown on display\n- If pairing fails: forget device, reboot, re-pair"},
|
||||
map[string]interface{}{"emoji": "📻", "title": "Set the right frequency preset", "description": "**US Recommended:**\n`910.525 MHz · BW 62.5 kHz · SF 7 · CR 5`\nSelect **\"US Recommended\"** in the app or flasher."},
|
||||
map[string]interface{}{"emoji": "📡", "title": "Advertise yourself", "description": "Tap the signal icon → **Flood** to broadcast your node to the mesh. Companions only advert when you trigger it manually."},
|
||||
map[string]interface{}{"emoji": "🔁", "title": "Check \"Heard N repeats\"", "description": "- **\"Sent\"** = transmitted, no confirmation\n- **\"Heard 0 repeats\"** = no repeater picked it up\n- **\"Heard 1+ repeats\"** = you're on the mesh!"},
|
||||
},
|
||||
"footerLinks": []interface{}{
|
||||
map[string]interface{}{"label": "📦 Packets", "url": "#/packets"},
|
||||
map[string]interface{}{"label": "🗺️ Network Map", "url": "#/map"},
|
||||
},
|
||||
var home interface{}
|
||||
if theme.Home != nil {
|
||||
home = theme.Home
|
||||
} else if s.cfg.Home != nil {
|
||||
home = s.cfg.Home
|
||||
}
|
||||
home := mergeMap(defaultHome, s.cfg.Home, theme.Home)
|
||||
|
||||
writeJSON(w, ThemeResponse{
|
||||
Branding: branding,
|
||||
@@ -1088,7 +1015,7 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
return cached
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.store.graph)
|
||||
r := pm.resolve(hop)
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
@@ -1376,31 +1303,6 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||||
hops := strings.Split(hopsParam, ",")
|
||||
resolved := map[string]*HopResolution{}
|
||||
|
||||
// Context for affinity-based disambiguation.
|
||||
fromNode := r.URL.Query().Get("from_node")
|
||||
observer := r.URL.Query().Get("observer")
|
||||
var contextPubkeys []string
|
||||
if fromNode != "" {
|
||||
contextPubkeys = append(contextPubkeys, fromNode)
|
||||
}
|
||||
if observer != "" {
|
||||
contextPubkeys = append(contextPubkeys, observer)
|
||||
}
|
||||
|
||||
// Get the neighbor graph for affinity scoring (may be nil).
|
||||
var graph *NeighborGraph
|
||||
if len(contextPubkeys) > 0 {
|
||||
graph = s.getNeighborGraph()
|
||||
}
|
||||
|
||||
// Get the server's prefix map for resolveWithContext.
|
||||
var pm *prefixMap
|
||||
if s.store != nil {
|
||||
s.store.mu.RLock()
|
||||
_, pm = s.store.getCachedNodesAndPM()
|
||||
s.store.mu.RUnlock()
|
||||
}
|
||||
|
||||
for _, hop := range hops {
|
||||
if hop == "" {
|
||||
continue
|
||||
@@ -1408,7 +1310,7 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||||
hopLower := strings.ToLower(hop)
|
||||
rows, err := s.db.conn.Query("SELECT public_key, name, lat, lon FROM nodes WHERE LOWER(public_key) LIKE ?", hopLower+"%")
|
||||
if err != nil {
|
||||
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}, Confidence: "ambiguous"}
|
||||
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1426,77 +1328,18 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||||
rows.Close()
|
||||
|
||||
if len(candidates) == 0 {
|
||||
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}, Confidence: "no_match"}
|
||||
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}}
|
||||
} else if len(candidates) == 1 {
|
||||
resolved[hop] = &HopResolution{
|
||||
Name: candidates[0].Name, Pubkey: candidates[0].Pubkey,
|
||||
Candidates: candidates, Conflicts: []interface{}{},
|
||||
Confidence: "unique_prefix",
|
||||
}
|
||||
} else {
|
||||
// Compute affinity scores for each candidate if we have context.
|
||||
if graph != nil && len(contextPubkeys) > 0 {
|
||||
now := time.Now()
|
||||
for i := range candidates {
|
||||
candPK := strings.ToLower(candidates[i].Pubkey)
|
||||
bestScore := 0.0
|
||||
for _, ctxPK := range contextPubkeys {
|
||||
edges := graph.Neighbors(strings.ToLower(ctxPK))
|
||||
for _, e := range edges {
|
||||
if e.Ambiguous {
|
||||
continue
|
||||
}
|
||||
otherPK := e.NodeA
|
||||
if strings.EqualFold(otherPK, ctxPK) {
|
||||
otherPK = e.NodeB
|
||||
}
|
||||
if strings.EqualFold(otherPK, candPK) {
|
||||
sc := e.Score(now)
|
||||
if sc > bestScore {
|
||||
bestScore = sc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestScore > 0 {
|
||||
s := bestScore
|
||||
candidates[i].AffinityScore = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use resolveWithContext for 4-tier disambiguation.
|
||||
var best *nodeInfo
|
||||
var confidence string
|
||||
if pm != nil {
|
||||
best, confidence, _ = pm.resolveWithContext(hopLower, contextPubkeys, graph)
|
||||
}
|
||||
|
||||
ambig := true
|
||||
hr := &HopResolution{
|
||||
resolved[hop] = &HopResolution{
|
||||
Name: candidates[0].Name, Pubkey: candidates[0].Pubkey,
|
||||
Ambiguous: &ambig, Candidates: candidates, Conflicts: hopCandidatesToConflicts(candidates),
|
||||
Confidence: "ambiguous",
|
||||
}
|
||||
|
||||
// Use the resolved node as the default (best-effort pick).
|
||||
if best != nil {
|
||||
hr.Name = best.Name
|
||||
hr.Pubkey = best.PublicKey
|
||||
}
|
||||
|
||||
// Only promote to bestCandidate when affinity is confident.
|
||||
if confidence == "neighbor_affinity" && best != nil {
|
||||
pk := best.PublicKey
|
||||
hr.BestCandidate = &pk
|
||||
hr.Confidence = "neighbor_affinity"
|
||||
} else if (confidence == "geo_proximity" || confidence == "gps_preference" || confidence == "first_match") && best != nil {
|
||||
// Propagate lower-priority tiers so the API reflects the actual
|
||||
// resolution strategy used, rather than collapsing everything to "ambiguous".
|
||||
hr.Confidence = confidence
|
||||
}
|
||||
|
||||
resolved[hop] = hr
|
||||
}
|
||||
}
|
||||
writeJSON(w, ResolveHopsResponse{Resolved: resolved})
|
||||
@@ -1958,7 +1801,13 @@ func percentile(sorted []float64, p float64) float64 {
|
||||
func sortedCopy(arr []float64) []float64 {
|
||||
cp := make([]float64, len(arr))
|
||||
copy(cp, arr)
|
||||
sort.Float64s(cp)
|
||||
for i := 0; i < len(cp); i++ {
|
||||
for j := i + 1; j < len(cp); j++ {
|
||||
if cp[j] < cp[i] {
|
||||
cp[i], cp[j] = cp[j], cp[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
@@ -1997,9 +1846,6 @@ func mapSliceToTransmissions(maps []map[string]interface{}) []TransmissionResp {
|
||||
tx.PathJSON = m["path_json"]
|
||||
tx.Direction = m["direction"]
|
||||
tx.Score = m["score"]
|
||||
if rp, ok := m["resolved_path"].([]*string); ok {
|
||||
tx.ResolvedPath = rp
|
||||
}
|
||||
result = append(result, tx)
|
||||
}
|
||||
return result
|
||||
@@ -2021,9 +1867,6 @@ func mapSliceToObservations(maps []map[string]interface{}) []ObservationResp {
|
||||
obs.RSSI = m["rssi"]
|
||||
obs.PathJSON = m["path_json"]
|
||||
obs.Timestamp = m["timestamp"]
|
||||
if rp, ok := m["resolved_path"].([]*string); ok {
|
||||
obs.ResolvedPath = rp
|
||||
}
|
||||
result = append(result, obs)
|
||||
}
|
||||
return result
|
||||
|
||||
+3
-253
@@ -1596,47 +1596,6 @@ func TestConfigThemeWithCustomConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigThemeHomeDefaults(t *testing.T) {
|
||||
// When no home config is set, server should return built-in defaults
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
cfg := &Config{Port: 3000} // no Home set
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config/theme", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
home, ok := body["home"].(map[string]interface{})
|
||||
if !ok || home == nil {
|
||||
t.Fatal("expected non-null home object in theme response")
|
||||
}
|
||||
if home["heroTitle"] != "CoreScope" {
|
||||
t.Errorf("expected heroTitle=CoreScope, got %v", home["heroTitle"])
|
||||
}
|
||||
if home["heroSubtitle"] == nil {
|
||||
t.Error("expected heroSubtitle in home defaults")
|
||||
}
|
||||
steps, ok := home["steps"].([]interface{})
|
||||
if !ok || len(steps) == 0 {
|
||||
t.Error("expected non-empty steps array in home defaults")
|
||||
}
|
||||
footerLinks, ok := home["footerLinks"].([]interface{})
|
||||
if !ok || len(footerLinks) == 0 {
|
||||
t.Error("expected non-empty footerLinks array in home defaults")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCacheWithCustomTTL(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
@@ -2219,124 +2178,6 @@ func TestGetNodeHashSizeInfoLatestWins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoIgnoreDirectZeroHop(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "dddd111122223333444455556666777788889999aaaabbbbccccddddeeee3333"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'DirIgnore', 'repeater')", pk)
|
||||
|
||||
decoded := `{"name":"DirIgnore","pubKey":"` + pk + `"}`
|
||||
rawFlood2B := "11" + "40" + "aabb" // FLOOD advert, hashSize=2
|
||||
rawDirect0 := "12" + "00" + "aabb" // DIRECT advert, zero-hop (should be ignored)
|
||||
|
||||
payloadType := 4
|
||||
raws := []string{rawFlood2B, rawDirect0, rawFlood2B, rawDirect0, rawFlood2B}
|
||||
for i, raw := range raws {
|
||||
tx := &StoreTx{
|
||||
ID: 9150 + i,
|
||||
RawHex: raw,
|
||||
Hash: "dirignore" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T0" + strconv.Itoa(i) + ":00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
}
|
||||
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
ni := info[pk]
|
||||
if ni == nil {
|
||||
t.Fatal("expected hash info for test node")
|
||||
}
|
||||
if ni.HashSize != 2 {
|
||||
t.Errorf("HashSize=%d, want 2 (direct zero-hop adverts should be ignored)", ni.HashSize)
|
||||
}
|
||||
if ni.Inconsistent {
|
||||
t.Error("expected hash_size_inconsistent=false when direct zero-hop adverts are ignored")
|
||||
}
|
||||
if len(ni.AllSizes) != 1 || !ni.AllSizes[2] {
|
||||
t.Errorf("expected only 2-byte size in AllSizes, got %#v", ni.AllSizes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoOnlyDirectZeroHopIgnored(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "eeee111122223333444455556666777788889999aaaabbbbccccddddeeee4444"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'OnlyDirect', 'repeater')", pk)
|
||||
|
||||
decoded := `{"name":"OnlyDirect","pubKey":"` + pk + `"}`
|
||||
rawDirect0 := "12" + "00" + "aabb"
|
||||
payloadType := 4
|
||||
|
||||
tx := &StoreTx{
|
||||
ID: 9160,
|
||||
RawHex: rawDirect0,
|
||||
Hash: "onlydirect0",
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
if ni := info[pk]; ni != nil {
|
||||
t.Errorf("expected nil hash info for direct zero-hop only node, got HashSize=%d", ni.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoDirectNonZeroHopCounted(t *testing.T) {
|
||||
// A DIRECT advert with non-zero hop count should NOT be skipped —
|
||||
// only zero-hop DIRECT adverts misreport hash size.
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "ffff111122223333444455556666777788889999aaaabbbbccccddddeeee5555"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'DirNonZero', 'repeater')", pk)
|
||||
|
||||
decoded := `{"name":"DirNonZero","pubKey":"` + pk + `"}`
|
||||
// DIRECT advert (route type 2 = 0x02 in bits 0-1), path byte 0x41:
|
||||
// upper 2 bits = 01 → hash_size = 2, lower 6 bits = 0x01 → hop count 1 (non-zero)
|
||||
rawDirectNonZero := "12" + "41" + "aabb" // header=0x12 (ADVERT|DIRECT), path=0x41
|
||||
payloadType := 4
|
||||
|
||||
tx := &StoreTx{
|
||||
ID: 9170,
|
||||
RawHex: rawDirectNonZero,
|
||||
Hash: "dirnonzero0",
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
ni := info[pk]
|
||||
if ni == nil {
|
||||
t.Fatal("expected hash info for DIRECT non-zero-hop node — it should NOT be skipped")
|
||||
}
|
||||
if ni.HashSize != 2 {
|
||||
t.Errorf("HashSize=%d, want 2 (DIRECT with hop count > 0 should be counted)", ni.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoNoAdverts(t *testing.T) {
|
||||
// A node with no ADVERT packets should not appear in hash size info.
|
||||
db := setupTestDB(t)
|
||||
@@ -3059,11 +2900,11 @@ func TestHashCollisionsWithCollision(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
// Two repeater nodes with same first byte 'CC' and hash_size=1
|
||||
// Two nodes with same first byte 'CC', no adverts so hash_size=0 (included in all buckets)
|
||||
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', 5)`, recent)
|
||||
VALUES ('CC11223344556677', 'Node1', 'repeater', 37.5, -122.0, ?, '2026-01-01T00:00:00Z', 0)`, recent)
|
||||
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', 5)`, recent)
|
||||
VALUES ('CC99887766554433', 'Node2', 'repeater', 37.51, -122.01, ?, '2026-01-01T00:00:00Z', 0)`, recent)
|
||||
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
@@ -3072,14 +2913,6 @@ func TestHashCollisionsWithCollision(t *testing.T) {
|
||||
if err := store.Load(); err != nil {
|
||||
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
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
@@ -3194,86 +3027,3 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
+121
-474
@@ -39,15 +39,10 @@ type StoreTx struct {
|
||||
RSSI *float64
|
||||
PathJSON string
|
||||
Direction string
|
||||
ResolvedPath []*string // resolved path from best observation
|
||||
LatestSeen string // max observation timestamp (or FirstSeen if no observations)
|
||||
// Cached parsed fields (set once, read many)
|
||||
parsedPath []string // cached parsePathJSON result
|
||||
pathParsed bool // whether parsedPath has been set
|
||||
decodedOnce sync.Once // guards parsedDecoded
|
||||
parsedDecoded map[string]interface{} // cached json.Unmarshal of DecodedJSON
|
||||
// Dedup map: "observerID|pathJSON" → true for O(1) duplicate checks
|
||||
obsKeys map[string]bool
|
||||
parsedPath []string // cached parsePathJSON result
|
||||
pathParsed bool // whether parsedPath has been set
|
||||
}
|
||||
|
||||
// StoreObs is a lean in-memory observation (no duplication of transmission fields).
|
||||
@@ -61,25 +56,9 @@ type StoreObs struct {
|
||||
RSSI *float64
|
||||
Score *int
|
||||
PathJSON string
|
||||
ResolvedPath []*string // resolved full pubkeys, parallel to path_json; nil elements = unresolved
|
||||
Timestamp string
|
||||
}
|
||||
|
||||
// ParsedDecoded returns the parsed DecodedJSON map, caching the result.
|
||||
// Thread-safe via sync.Once — the first call parses, subsequent calls return cached.
|
||||
func (tx *StoreTx) ParsedDecoded() map[string]interface{} {
|
||||
tx.decodedOnce.Do(func() {
|
||||
if tx.DecodedJSON != "" {
|
||||
json.Unmarshal([]byte(tx.DecodedJSON), &tx.parsedDecoded)
|
||||
}
|
||||
})
|
||||
return tx.parsedDecoded
|
||||
}
|
||||
|
||||
// distRebuildInterval is the minimum time between distance index rebuilds
|
||||
// to avoid hot-looping on busy meshes.
|
||||
const distRebuildInterval = 30 * time.Second
|
||||
|
||||
// PacketStore holds all transmissions in memory with indexes for fast queries.
|
||||
type PacketStore struct {
|
||||
mu sync.RWMutex
|
||||
@@ -109,10 +88,6 @@ type PacketStore struct {
|
||||
collisionCacheTTL time.Duration
|
||||
cacheHits 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)
|
||||
groupedCacheMu sync.Mutex
|
||||
groupedCacheKey string
|
||||
@@ -136,21 +111,12 @@ type PacketStore struct {
|
||||
// computed during Load() and incrementally updated on ingest.
|
||||
distHops []distHopRecord
|
||||
distPaths []distPathRecord
|
||||
distDirty bool // set when paths change; cleared after rebuild
|
||||
distLast time.Time // last time distance index was rebuilt
|
||||
|
||||
// Cached GetNodeHashSizeInfo result — recomputed at most once every 15s
|
||||
hashSizeInfoMu sync.Mutex
|
||||
hashSizeInfoCache map[string]*hashSizeNodeInfo
|
||||
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
|
||||
|
||||
// Persisted neighbor graph for hop resolution at ingest time.
|
||||
graph *NeighborGraph
|
||||
|
||||
// Eviction config and stats
|
||||
retentionHours float64 // 0 = unlimited
|
||||
maxMemoryMB int // 0 = unlimited
|
||||
@@ -216,9 +182,7 @@ func NewPacketStore(db *DB, cfg *PacketStoreConfig) *PacketStore {
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
collisionCacheTTL: 60 * time.Second,
|
||||
invCooldown: 10 * time.Second,
|
||||
spIndex: make(map[string]int, 4096),
|
||||
advertPubkeys: make(map[string]int),
|
||||
}
|
||||
if cfg != nil {
|
||||
ps.retentionHours = cfg.RetentionHours
|
||||
@@ -235,15 +199,11 @@ func (s *PacketStore) Load() error {
|
||||
t0 := time.Now()
|
||||
|
||||
var loadSQL string
|
||||
rpCol := ""
|
||||
if s.db.hasResolvedPath {
|
||||
rpCol = ",\n\t\t\t\to.resolved_path"
|
||||
}
|
||||
if s.db.isV3 {
|
||||
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
o.id, obs.id, obs.name, o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + rpCol + `
|
||||
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
@@ -252,7 +212,7 @@ func (s *PacketStore) Load() error {
|
||||
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
o.id, o.observer_id, o.observer_name, o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + rpCol + `
|
||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
ORDER BY t.first_seen ASC, o.timestamp DESC`
|
||||
@@ -272,16 +232,11 @@ func (s *PacketStore) Load() error {
|
||||
var observerID, observerName, direction, pathJSON, obsTimestamp sql.NullString
|
||||
var snr, rssi sql.NullFloat64
|
||||
var score sql.NullInt64
|
||||
var resolvedPathStr sql.NullString
|
||||
|
||||
scanArgs := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
|
||||
if err := rows.Scan(&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
|
||||
&payloadVersion, &decodedJSON,
|
||||
&obsID, &observerID, &observerName, &direction,
|
||||
&snr, &rssi, &score, &pathJSON, &obsTimestamp}
|
||||
if s.db.hasResolvedPath {
|
||||
scanArgs = append(scanArgs, &resolvedPathStr)
|
||||
}
|
||||
if err := rows.Scan(scanArgs...); err != nil {
|
||||
&snr, &rssi, &score, &pathJSON, &obsTimestamp); err != nil {
|
||||
log.Printf("[store] scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
@@ -298,7 +253,6 @@ func (s *PacketStore) Load() error {
|
||||
RouteType: nullIntPtr(routeType),
|
||||
PayloadType: nullIntPtr(payloadType),
|
||||
DecodedJSON: nullStrVal(decodedJSON),
|
||||
obsKeys: make(map[string]bool),
|
||||
}
|
||||
s.byHash[hashStr] = tx
|
||||
s.packets = append(s.packets, tx)
|
||||
@@ -308,7 +262,6 @@ func (s *PacketStore) Load() error {
|
||||
pt := *tx.PayloadType
|
||||
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
||||
}
|
||||
s.trackAdvertPubkey(tx)
|
||||
}
|
||||
|
||||
if obsID.Valid {
|
||||
@@ -316,9 +269,15 @@ func (s *PacketStore) Load() error {
|
||||
obsIDStr := nullStrVal(observerID)
|
||||
obsPJ := nullStrVal(pathJSON)
|
||||
|
||||
// Dedup: skip if same observer + same path already loaded (O(1) map lookup)
|
||||
dk := obsIDStr + "|" + obsPJ
|
||||
if tx.obsKeys[dk] {
|
||||
// Dedup: skip if same observer + same path already loaded
|
||||
isDupe := false
|
||||
for _, existing := range tx.Observations {
|
||||
if existing.ObserverID == obsIDStr && existing.PathJSON == obsPJ {
|
||||
isDupe = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isDupe {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -332,12 +291,10 @@ func (s *PacketStore) Load() error {
|
||||
RSSI: nullFloatPtr(rssi),
|
||||
Score: nullIntPtr(score),
|
||||
PathJSON: obsPJ,
|
||||
ResolvedPath: unmarshalResolvedPath(nullStrVal(resolvedPathStr)),
|
||||
Timestamp: normalizeTimestamp(nullStrVal(obsTimestamp)),
|
||||
}
|
||||
|
||||
tx.Observations = append(tx.Observations, obs)
|
||||
tx.obsKeys[dk] = true
|
||||
tx.ObservationCount++
|
||||
if obs.Timestamp > tx.LatestSeen {
|
||||
tx.LatestSeen = obs.Timestamp
|
||||
@@ -363,7 +320,6 @@ func (s *PacketStore) Load() error {
|
||||
|
||||
// Precompute distance analytics (hop distances, path totals)
|
||||
s.buildDistanceIndex()
|
||||
s.distLast = time.Now()
|
||||
|
||||
s.loaded = true
|
||||
elapsed := time.Since(t0)
|
||||
@@ -394,7 +350,6 @@ func pickBestObservation(tx *StoreTx) {
|
||||
tx.RSSI = best.RSSI
|
||||
tx.PathJSON = best.PathJSON
|
||||
tx.Direction = best.Direction
|
||||
tx.ResolvedPath = best.ResolvedPath
|
||||
tx.pathParsed = false // invalidate cached parsed path
|
||||
}
|
||||
|
||||
@@ -414,13 +369,8 @@ func (s *PacketStore) indexByNode(tx *StoreTx) {
|
||||
if tx.DecodedJSON == "" {
|
||||
return
|
||||
}
|
||||
// All three target fields ("pubKey", "destPubKey", "srcPubKey") share the
|
||||
// common suffix "ubKey" — skip JSON parse for packets that have none of them.
|
||||
if !strings.Contains(tx.DecodedJSON, "ubKey") {
|
||||
return
|
||||
}
|
||||
decoded := tx.ParsedDecoded()
|
||||
if decoded == nil {
|
||||
var decoded map[string]interface{}
|
||||
if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) != nil {
|
||||
return
|
||||
}
|
||||
for _, field := range []string{"pubKey", "destPubKey", "srcPubKey"} {
|
||||
@@ -437,52 +387,6 @@ 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
|
||||
}
|
||||
d := tx.ParsedDecoded()
|
||||
if 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.
|
||||
func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
|
||||
atomic.AddInt64(&s.queryCount, 1)
|
||||
@@ -594,9 +498,6 @@ func (s *PacketStore) QueryGroupedPackets(q PacketQuery) *PacketResult {
|
||||
"rssi": floatPtrOrNil(tx.RSSI),
|
||||
},
|
||||
})
|
||||
if tx.ResolvedPath != nil {
|
||||
entries[len(entries)-1].latest["resolved_path"] = tx.ResolvedPath
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
@@ -673,8 +574,30 @@ func (s *PacketStore) GetPerfStoreStats() map[string]interface{} {
|
||||
nodeIdx := len(s.byNode)
|
||||
ptIdx := len(s.byPayloadType)
|
||||
|
||||
// Distinct advert pubkey count — precomputed incrementally (see trackAdvertPubkey).
|
||||
advertByObsCount := len(s.advertPubkeys)
|
||||
// Count distinct pubkeys with ADVERT observations (matches Node.js _advertByObserver.size)
|
||||
advertByObsCount := 0
|
||||
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()
|
||||
|
||||
// Realistic estimate: ~5KB per packet + ~500 bytes per observation
|
||||
@@ -762,16 +685,15 @@ type cacheInvalidation struct {
|
||||
}
|
||||
|
||||
// invalidateCachesFor selectively clears only the analytics caches affected
|
||||
// by the kind of data that changed. To prevent continuous ingestion from
|
||||
// defeating caching entirely (issue #533), invalidation is rate-limited:
|
||||
// if called within invCooldown of the last invalidation, the flags are
|
||||
// accumulated in pendingInv and applied on the next call after cooldown.
|
||||
// by the kind of data that changed. This avoids the previous behaviour of
|
||||
// wiping every cache on every ingest cycle, which defeated caching under
|
||||
// continuous ingestion (issue #375).
|
||||
func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
// Eviction bypasses rate-limiting — data was removed, caches must clear.
|
||||
if inv.eviction {
|
||||
// Eviction can affect any analytics — clear everything
|
||||
s.rfCache = make(map[string]*cachedResult)
|
||||
s.topoCache = make(map[string]*cachedResult)
|
||||
s.hashCache = make(map[string]*cachedResult)
|
||||
@@ -782,40 +704,9 @@ func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
||||
s.channelsCacheMu.Lock()
|
||||
s.channelsCacheRes = nil
|
||||
s.channelsCacheMu.Unlock()
|
||||
s.lastInvalidated = time.Now()
|
||||
s.pendingInv = nil
|
||||
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 {
|
||||
s.rfCache = make(map[string]*cachedResult)
|
||||
}
|
||||
@@ -830,6 +721,7 @@ func (s *PacketStore) applyCacheInvalidation(inv cacheInvalidation) {
|
||||
}
|
||||
if inv.hasChannelData {
|
||||
s.chanCache = make(map[string]*cachedResult)
|
||||
// Also invalidate the separate channels list cache
|
||||
s.channelsCacheMu.Lock()
|
||||
s.channelsCacheRes = nil
|
||||
s.channelsCacheMu.Unlock()
|
||||
@@ -845,7 +737,29 @@ func (s *PacketStore) GetPerfStoreStatsTyped() PerfPacketStoreStats {
|
||||
observerIdx := len(s.byObserver)
|
||||
nodeIdx := len(s.byNode)
|
||||
|
||||
advertByObsCount := len(s.advertPubkeys)
|
||||
advertByObsCount := 0
|
||||
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()
|
||||
|
||||
estimatedMB := math.Round(float64(totalLoaded*5120+totalObs*500)/1048576*10) / 10
|
||||
@@ -860,7 +774,7 @@ func (s *PacketStore) GetPerfStoreStatsTyped() PerfPacketStoreStats {
|
||||
SqliteOnly: false,
|
||||
MaxPackets: 2386092,
|
||||
EstimatedMB: estimatedMB,
|
||||
MaxMB: s.maxMemoryMB,
|
||||
MaxMB: 1024,
|
||||
Indexes: PacketStoreIndexes{
|
||||
ByHash: hashIdx,
|
||||
ByObserver: observerIdx,
|
||||
@@ -1022,9 +936,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// NOTE: The SQL query intentionally does NOT select resolved_path from the DB.
|
||||
// New ingests always resolve fresh using the current prefix map and neighbor graph.
|
||||
// On restart, Load() handles reading persisted resolved_path values. (review item #7)
|
||||
var querySQL string
|
||||
if s.db.isV3 {
|
||||
querySQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
@@ -1129,10 +1040,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
broadcastTxs := make(map[int]*StoreTx) // track new transmissions for broadcast
|
||||
var broadcastOrder []int
|
||||
|
||||
// Hoist getCachedNodesAndPM() once before the observation loop to avoid
|
||||
// per-observation function calls (review item #1).
|
||||
_, cachedPM := s.getCachedNodesAndPM()
|
||||
|
||||
for _, r := range tempRows {
|
||||
if r.txID > newMaxID {
|
||||
newMaxID = r.txID
|
||||
@@ -1149,7 +1056,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
RouteType: r.routeType,
|
||||
PayloadType: r.payloadType,
|
||||
DecodedJSON: r.decodedJSON,
|
||||
obsKeys: make(map[string]bool),
|
||||
}
|
||||
s.byHash[r.hash] = tx
|
||||
s.packets = append(s.packets, tx) // oldest-first; new items go to tail
|
||||
@@ -1161,7 +1067,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
// so GetChannelMessages reverse iteration stays correct
|
||||
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
||||
}
|
||||
s.trackAdvertPubkey(tx)
|
||||
|
||||
if _, exists := broadcastTxs[r.txID]; !exists {
|
||||
broadcastTxs[r.txID] = tx
|
||||
@@ -1171,12 +1076,15 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
|
||||
if r.obsID != nil {
|
||||
oid := *r.obsID
|
||||
// Dedup (O(1) map lookup)
|
||||
dk := r.observerID + "|" + r.pathJSON
|
||||
if tx.obsKeys == nil {
|
||||
tx.obsKeys = make(map[string]bool)
|
||||
// Dedup
|
||||
isDupe := false
|
||||
for _, existing := range tx.Observations {
|
||||
if existing.ObserverID == r.observerID && existing.PathJSON == r.pathJSON {
|
||||
isDupe = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if tx.obsKeys[dk] {
|
||||
if isDupe {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1192,15 +1100,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
PathJSON: r.pathJSON,
|
||||
Timestamp: normalizeTimestamp(r.obsTS),
|
||||
}
|
||||
|
||||
// Resolve path at ingest time using neighbor graph
|
||||
// (cachedPM is hoisted before the observation loop to avoid per-obs function calls)
|
||||
if r.pathJSON != "" && r.pathJSON != "[]" && cachedPM != nil {
|
||||
obs.ResolvedPath = resolvePathForObs(r.pathJSON, r.observerID, tx, cachedPM, s.graph)
|
||||
}
|
||||
|
||||
tx.Observations = append(tx.Observations, obs)
|
||||
tx.obsKeys[dk] = true
|
||||
tx.ObservationCount++
|
||||
if obs.Timestamp > tx.LatestSeen {
|
||||
tx.LatestSeen = obs.Timestamp
|
||||
@@ -1242,7 +1142,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
return cached
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
r := pm.resolve(hop)
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
@@ -1292,9 +1192,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
"direction": strOrNil(obs.Direction),
|
||||
"observation_count": tx.ObservationCount,
|
||||
}
|
||||
if obs.ResolvedPath != nil {
|
||||
pkt["resolved_path"] = obs.ResolvedPath
|
||||
}
|
||||
// Broadcast map: top-level fields for live.js + nested packet for packets.js
|
||||
broadcastMap := make(map[string]interface{}, len(pkt)+2)
|
||||
for k, v := range pkt {
|
||||
@@ -1329,36 +1226,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
s.invalidateCachesFor(inv)
|
||||
}
|
||||
|
||||
// Persist resolved paths and neighbor edges asynchronously (don't block ingest).
|
||||
if len(broadcastTxs) > 0 && s.db != nil {
|
||||
dbPath := s.db.path
|
||||
var obsUpdates []persistObsUpdate
|
||||
var edgeUpdates []persistEdgeUpdate
|
||||
|
||||
_, pm := s.getCachedNodesAndPM()
|
||||
// Read graph ref under lock (it's set during startup and not replaced after,
|
||||
// but reading under lock is safer — review item #5).
|
||||
graphRef := s.graph
|
||||
for _, tx := range broadcastTxs {
|
||||
for _, obs := range tx.Observations {
|
||||
if obs.ResolvedPath != nil {
|
||||
rpJSON := marshalResolvedPath(obs.ResolvedPath)
|
||||
if rpJSON != "" {
|
||||
obsUpdates = append(obsUpdates, persistObsUpdate{obs.ID, rpJSON})
|
||||
}
|
||||
}
|
||||
for _, ec := range extractEdgesFromObs(obs, tx, pm) {
|
||||
edgeUpdates = append(edgeUpdates, persistEdgeUpdate{ec.A, ec.B, ec.Timestamp})
|
||||
if graphRef != nil {
|
||||
graphRef.upsertEdge(ec.A, ec.B, "", obs.ObserverID, obs.SNR, parseTimestamp(ec.Timestamp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
asyncPersistResolvedPathsAndEdges(dbPath, obsUpdates, edgeUpdates, "persist")
|
||||
}
|
||||
|
||||
return result, newMaxID
|
||||
}
|
||||
|
||||
@@ -1442,13 +1309,6 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
|
||||
updatedTxs := make(map[int]*StoreTx)
|
||||
broadcastMaps := make([]map[string]interface{}, 0, len(obsRows))
|
||||
// Track newly created observations for persistence — only these should be
|
||||
// persisted, not all observations of each updated tx (fixes edge count inflation).
|
||||
var newObs []*StoreObs
|
||||
|
||||
// Hoist getCachedNodesAndPM() before the loop — same pattern as IngestNewFromDB (review fix #1).
|
||||
_, pm := s.getCachedNodesAndPM()
|
||||
graphRef := s.graph
|
||||
|
||||
for _, r := range obsRows {
|
||||
// Already ingested (e.g. by IngestNewFromDB in same cycle)
|
||||
@@ -1461,12 +1321,15 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
continue // transmission not yet in store
|
||||
}
|
||||
|
||||
// Dedup by observer + path (O(1) map lookup)
|
||||
dk := r.observerID + "|" + r.pathJSON
|
||||
if tx.obsKeys == nil {
|
||||
tx.obsKeys = make(map[string]bool)
|
||||
// Dedup by observer + path
|
||||
isDupe := false
|
||||
for _, existing := range tx.Observations {
|
||||
if existing.ObserverID == r.observerID && existing.PathJSON == r.pathJSON {
|
||||
isDupe = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if tx.obsKeys[dk] {
|
||||
if isDupe {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1482,18 +1345,8 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
PathJSON: r.pathJSON,
|
||||
Timestamp: normalizeTimestamp(r.timestamp),
|
||||
}
|
||||
|
||||
// Resolve path at ingest time for late-arriving observations (review item #2).
|
||||
if r.pathJSON != "" && r.pathJSON != "[]" {
|
||||
if pm != nil {
|
||||
obs.ResolvedPath = resolvePathForObs(r.pathJSON, r.observerID, tx, pm, s.graph)
|
||||
}
|
||||
}
|
||||
|
||||
tx.Observations = append(tx.Observations, obs)
|
||||
tx.obsKeys[dk] = true
|
||||
tx.ObservationCount++
|
||||
newObs = append(newObs, obs)
|
||||
if obs.Timestamp > tx.LatestSeen {
|
||||
tx.LatestSeen = obs.Timestamp
|
||||
}
|
||||
@@ -1533,9 +1386,6 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
"direction": strOrNil(obs.Direction),
|
||||
"observation_count": tx.ObservationCount,
|
||||
}
|
||||
if obs.ResolvedPath != nil {
|
||||
pkt["resolved_path"] = obs.ResolvedPath
|
||||
}
|
||||
broadcastMap := make(map[string]interface{}, len(pkt)+2)
|
||||
for k, v := range pkt {
|
||||
broadcastMap[k] = v
|
||||
@@ -1575,65 +1425,31 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any paths changed (used for both distance rebuild and cache invalidation).
|
||||
hasPathChanges := false
|
||||
// Rebuild distance index if any paths changed (distances depend on path hops)
|
||||
for txID, tx := range updatedTxs {
|
||||
if tx.PathJSON != oldPaths[txID] {
|
||||
hasPathChanges = true
|
||||
s.buildDistanceIndex()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Mark distance index dirty if any paths changed (rebuild is debounced)
|
||||
if hasPathChanges {
|
||||
s.distDirty = true
|
||||
}
|
||||
if s.distDirty && time.Since(s.distLast) > distRebuildInterval {
|
||||
s.buildDistanceIndex()
|
||||
s.distDirty = false
|
||||
s.distLast = time.Now()
|
||||
}
|
||||
|
||||
if len(updatedTxs) > 0 {
|
||||
// Targeted cache invalidation: new observations always affect RF
|
||||
// analytics; topology/distance/subpath caches only if paths changed.
|
||||
// Channel and hash caches are unaffected by observation-only ingestion.
|
||||
hasPathChanges := false
|
||||
for txID, tx := range updatedTxs {
|
||||
if tx.PathJSON != oldPaths[txID] {
|
||||
hasPathChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
s.invalidateCachesFor(cacheInvalidation{
|
||||
hasNewObservations: true,
|
||||
hasNewPaths: hasPathChanges,
|
||||
})
|
||||
}
|
||||
|
||||
// Persist resolved paths and neighbor edges asynchronously (review fix #3).
|
||||
// Only process NEW observations — not all observations of each updated tx —
|
||||
// to avoid edge count inflation and unnecessary UPDATEs for pre-existing data.
|
||||
if len(newObs) > 0 && s.db != nil {
|
||||
dbPath := s.db.path
|
||||
var obsUpdates []persistObsUpdate
|
||||
var edgeUpdates []persistEdgeUpdate
|
||||
|
||||
for _, obs := range newObs {
|
||||
tx := s.byTxID[obs.TransmissionID]
|
||||
if tx == nil {
|
||||
continue
|
||||
}
|
||||
if obs.ResolvedPath != nil {
|
||||
rpJSON := marshalResolvedPath(obs.ResolvedPath)
|
||||
if rpJSON != "" {
|
||||
obsUpdates = append(obsUpdates, persistObsUpdate{obs.ID, rpJSON})
|
||||
}
|
||||
}
|
||||
for _, ec := range extractEdgesFromObs(obs, tx, pm) {
|
||||
edgeUpdates = append(edgeUpdates, persistEdgeUpdate{ec.A, ec.B, ec.Timestamp})
|
||||
if graphRef != nil {
|
||||
graphRef.upsertEdge(ec.A, ec.B, "", obs.ObserverID, obs.SNR, parseTimestamp(ec.Timestamp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
asyncPersistResolvedPathsAndEdges(dbPath, obsUpdates, edgeUpdates, "obs-persist")
|
||||
}
|
||||
|
||||
return broadcastMaps
|
||||
}
|
||||
|
||||
@@ -1751,36 +1567,32 @@ func (s *PacketStore) filterPackets(q PacketQuery) []*StoreTx {
|
||||
}
|
||||
|
||||
// transmissionsForObserver returns unique transmissions for an observer.
|
||||
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
|
||||
}
|
||||
func (s *PacketStore) transmissionsForObserver(observerID string, from []*StoreTx) []*StoreTx {
|
||||
if from != nil {
|
||||
return filterTxSlice(from, func(tx *StoreTx) bool {
|
||||
for _, obs := range tx.Observations {
|
||||
if idSet[obs.ObserverID] {
|
||||
if obs.ObserverID == observerID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
// Use byObserver index: union transmissions for all IDs
|
||||
seen := make(map[int]bool)
|
||||
// Use byObserver index
|
||||
observations := s.byObserver[observerID]
|
||||
if len(observations) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int]bool, len(observations))
|
||||
var result []*StoreTx
|
||||
for _, id := range ids {
|
||||
for _, obs := range s.byObserver[id] {
|
||||
if seen[obs.TransmissionID] {
|
||||
continue
|
||||
}
|
||||
seen[obs.TransmissionID] = true
|
||||
tx := s.byTxID[obs.TransmissionID]
|
||||
if tx != nil {
|
||||
result = append(result, tx)
|
||||
}
|
||||
for _, obs := range observations {
|
||||
if seen[obs.TransmissionID] {
|
||||
continue
|
||||
}
|
||||
seen[obs.TransmissionID] = true
|
||||
tx := s.byTxID[obs.TransmissionID]
|
||||
if tx != nil {
|
||||
result = append(result, tx)
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -1814,9 +1626,6 @@ func (s *PacketStore) enrichObs(obs *StoreObs) map[string]interface{} {
|
||||
"score": intPtrOrNil(obs.Score),
|
||||
"path_json": strOrNil(obs.PathJSON),
|
||||
}
|
||||
if obs.ResolvedPath != nil {
|
||||
m["resolved_path"] = obs.ResolvedPath
|
||||
}
|
||||
|
||||
if tx != nil {
|
||||
m["hash"] = strOrNil(tx.Hash)
|
||||
@@ -1850,9 +1659,6 @@ func txToMap(tx *StoreTx) map[string]interface{} {
|
||||
"path_json": strOrNil(tx.PathJSON),
|
||||
"direction": strOrNil(tx.Direction),
|
||||
}
|
||||
if tx.ResolvedPath != nil {
|
||||
m["resolved_path"] = tx.ResolvedPath
|
||||
}
|
||||
// Include parsed path array to match Node.js output shape
|
||||
if hops := txGetParsedPath(tx); len(hops) > 0 {
|
||||
m["_parsedPath"] = hops
|
||||
@@ -1862,7 +1668,7 @@ func txToMap(tx *StoreTx) map[string]interface{} {
|
||||
// Include observations for expand=observations support (stripped by handler when not requested)
|
||||
obs := make([]map[string]interface{}, 0, len(tx.Observations))
|
||||
for _, o := range tx.Observations {
|
||||
om := map[string]interface{}{
|
||||
obs = append(obs, map[string]interface{}{
|
||||
"id": o.ID,
|
||||
"observer_id": strOrNil(o.ObserverID),
|
||||
"observer_name": strOrNil(o.ObserverName),
|
||||
@@ -1871,11 +1677,7 @@ func txToMap(tx *StoreTx) map[string]interface{} {
|
||||
"path_json": strOrNil(o.PathJSON),
|
||||
"timestamp": strOrNil(o.Timestamp),
|
||||
"direction": strOrNil(o.Direction),
|
||||
}
|
||||
if o.ResolvedPath != nil {
|
||||
om["resolved_path"] = o.ResolvedPath
|
||||
}
|
||||
obs = append(obs, om)
|
||||
})
|
||||
}
|
||||
m["observations"] = obs
|
||||
return m
|
||||
@@ -2022,7 +1824,7 @@ func (s *PacketStore) buildDistanceIndex() {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
return cached
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
r := pm.resolve(hop)
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
@@ -2132,7 +1934,6 @@ func (s *PacketStore) EvictStale() int {
|
||||
}
|
||||
|
||||
// Remove from byPayloadType
|
||||
s.untrackAdvertPubkey(tx)
|
||||
if tx.PayloadType != nil {
|
||||
pt := *tx.PayloadType
|
||||
ptList := s.byPayloadType[pt]
|
||||
@@ -3498,144 +3299,6 @@ func (pm *prefixMap) resolve(hop string) *nodeInfo {
|
||||
return &candidates[0]
|
||||
}
|
||||
|
||||
// resolveWithContext resolves a hop prefix using the neighbor affinity graph
|
||||
// for disambiguation when multiple candidates match. It applies a 4-tier
|
||||
// priority: (1) affinity graph score, (2) geographic proximity to context
|
||||
// nodes, (3) GPS preference, (4) first match fallback.
|
||||
//
|
||||
// contextPubkeys are pubkeys of nodes that provide context for disambiguation
|
||||
// (e.g., the originator, observer, or adjacent hops in the path).
|
||||
// graph may be nil, in which case it falls back to the existing resolve().
|
||||
func (pm *prefixMap) resolveWithContext(hop string, contextPubkeys []string, graph *NeighborGraph) (*nodeInfo, string, float64) {
|
||||
h := strings.ToLower(hop)
|
||||
candidates := pm.m[h]
|
||||
if len(candidates) == 0 {
|
||||
return nil, "no_match", 0
|
||||
}
|
||||
if len(candidates) == 1 {
|
||||
return &candidates[0], "unique_prefix", 1.0
|
||||
}
|
||||
|
||||
// Priority 1: Affinity graph score
|
||||
//
|
||||
// NOTE: We use raw Score() (count × time-decay) here rather than Jaccard
|
||||
// similarity. Jaccard is used at the graph builder level (disambiguate() in
|
||||
// neighbor_graph.go) to resolve ambiguous edges by comparing neighbor-set
|
||||
// overlap. Here, edges are already resolved — we just need to pick the
|
||||
// highest-affinity candidate among them. Raw score is appropriate because
|
||||
// it reflects both observation frequency and recency, which are the right
|
||||
// signals for "which candidate is this hop most likely referring to."
|
||||
if graph != nil && len(contextPubkeys) > 0 {
|
||||
type scored struct {
|
||||
idx int
|
||||
score float64
|
||||
count int // observation count of the best-scoring edge
|
||||
}
|
||||
now := time.Now()
|
||||
var scores []scored
|
||||
for i, cand := range candidates {
|
||||
candPK := strings.ToLower(cand.PublicKey)
|
||||
bestScore := 0.0
|
||||
bestCount := 0
|
||||
for _, ctxPK := range contextPubkeys {
|
||||
edges := graph.Neighbors(strings.ToLower(ctxPK))
|
||||
for _, e := range edges {
|
||||
if e.Ambiguous {
|
||||
continue
|
||||
}
|
||||
otherPK := e.NodeA
|
||||
if strings.EqualFold(otherPK, ctxPK) {
|
||||
otherPK = e.NodeB
|
||||
}
|
||||
if strings.EqualFold(otherPK, candPK) {
|
||||
s := e.Score(now)
|
||||
if s > bestScore {
|
||||
bestScore = s
|
||||
bestCount = e.Count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestScore > 0 {
|
||||
scores = append(scores, scored{i, bestScore, bestCount})
|
||||
}
|
||||
}
|
||||
|
||||
if len(scores) >= 1 {
|
||||
// Sort descending
|
||||
for i := 0; i < len(scores)-1; i++ {
|
||||
for j := i + 1; j < len(scores); j++ {
|
||||
if scores[j].score > scores[i].score {
|
||||
scores[i], scores[j] = scores[j], scores[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
best := scores[0]
|
||||
// Require both score ratio ≥ 3× AND minimum observations (mirrors
|
||||
// disambiguate() in neighbor_graph.go which checks affinityMinObservations).
|
||||
if best.count >= affinityMinObservations &&
|
||||
(len(scores) == 1 || best.score >= affinityConfidenceRatio*scores[1].score) {
|
||||
return &candidates[best.idx], "neighbor_affinity", best.score
|
||||
}
|
||||
// Scores too close — fall through to lower-priority strategies
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Geographic proximity (if context pubkeys have GPS and candidates have GPS)
|
||||
if len(contextPubkeys) > 0 {
|
||||
// Find GPS positions of context nodes from the prefix map or candidates
|
||||
// We need nodeInfo for context pubkeys — look them up
|
||||
var contextLat, contextLon float64
|
||||
var contextGPSCount int
|
||||
for _, ctxPK := range contextPubkeys {
|
||||
ctxLower := strings.ToLower(ctxPK)
|
||||
if infos, ok := pm.m[ctxLower]; ok && len(infos) == 1 && infos[0].HasGPS {
|
||||
contextLat += infos[0].Lat
|
||||
contextLon += infos[0].Lon
|
||||
contextGPSCount++
|
||||
}
|
||||
}
|
||||
if contextGPSCount > 0 {
|
||||
contextLat /= float64(contextGPSCount)
|
||||
contextLon /= float64(contextGPSCount)
|
||||
|
||||
bestIdx := -1
|
||||
bestDist := math.MaxFloat64
|
||||
for i, cand := range candidates {
|
||||
if !cand.HasGPS {
|
||||
continue
|
||||
}
|
||||
d := geoDistApprox(contextLat, contextLon, cand.Lat, cand.Lon)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
if bestIdx >= 0 {
|
||||
return &candidates[bestIdx], "geo_proximity", 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: GPS preference
|
||||
for i := range candidates {
|
||||
if candidates[i].HasGPS {
|
||||
return &candidates[i], "gps_preference", 0
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: First match fallback
|
||||
return &candidates[0], "first_match", 0
|
||||
}
|
||||
|
||||
// geoDistApprox returns an approximate distance between two lat/lon points
|
||||
// (equirectangular approximation, sufficient for relative comparison).
|
||||
func geoDistApprox(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
dLat := (lat2 - lat1) * math.Pi / 180
|
||||
dLon := (lon2 - lon1) * math.Pi / 180 * math.Cos((lat1+lat2)/2*math.Pi/180)
|
||||
return math.Sqrt(dLat*dLat + dLon*dLon)
|
||||
}
|
||||
|
||||
func parsePathJSON(pathJSON string) []string {
|
||||
if pathJSON == "" || pathJSON == "[]" {
|
||||
return nil
|
||||
@@ -3683,7 +3346,7 @@ func (s *PacketStore) computeAnalyticsTopology(region string) map[string]interfa
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
return cached
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
r := pm.resolve(hop)
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
@@ -4667,16 +4330,10 @@ func (s *PacketStore) computeHashCollisions(region string) map[string]interface{
|
||||
// Compute collisions for each byte size (1, 2, 3)
|
||||
collisionsBySize := make(map[string]interface{})
|
||||
for _, bytes := range []int{1, 2, 3} {
|
||||
// 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)
|
||||
// Filter nodes relevant to this byte size
|
||||
var nodesForByte []collisionNode
|
||||
for _, cn := range allCNodes {
|
||||
if cn.HashSize == bytes && cn.Role == "repeater" {
|
||||
if cn.HashSize == bytes || cn.HashSize == 0 {
|
||||
nodesForByte = append(nodesForByte, cn)
|
||||
}
|
||||
}
|
||||
@@ -4880,20 +4537,10 @@ func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||||
if len(tx.RawHex) < 4 {
|
||||
continue
|
||||
}
|
||||
header, err := strconv.ParseUint(tx.RawHex[:2], 16, 8)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
routeType := int(header & 0x03)
|
||||
pathByte, err := strconv.ParseUint(tx.RawHex[2:4], 16, 8)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// DIRECT zero-hop adverts use path byte 0x00 locally and can misreport
|
||||
// multibyte repeater hash mode as 1-byte.
|
||||
if routeType == RouteDirect && (pathByte&0x3F) == 0 {
|
||||
continue
|
||||
}
|
||||
hs := int((pathByte>>6)&0x3) + 1
|
||||
|
||||
var d map[string]interface{}
|
||||
@@ -5674,7 +5321,7 @@ func (s *PacketStore) computeAnalyticsSubpaths(region string, minLen, maxLen, li
|
||||
}
|
||||
return hop
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
r := pm.resolve(hop)
|
||||
hopCache[hop] = r
|
||||
if r != nil {
|
||||
return r.Name
|
||||
@@ -5811,7 +5458,7 @@ func (s *PacketStore) GetSubpathDetail(rawHops []string) map[string]interface{}
|
||||
// Resolve the requested hops
|
||||
nodes := make([]map[string]interface{}, len(rawHops))
|
||||
for i, hop := range rawHops {
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
r := pm.resolve(hop)
|
||||
entry := map[string]interface{}{"hop": hop, "name": hop, "lat": nil, "lon": nil, "pubkey": nil}
|
||||
if r != nil {
|
||||
entry["name"] = r.Name
|
||||
@@ -5890,7 +5537,7 @@ func (s *PacketStore) GetSubpathDetail(rawHops []string) map[string]interface{}
|
||||
// Full parent path (resolved)
|
||||
resolved := make([]string, len(hops))
|
||||
for i, h := range hops {
|
||||
r, _, _ := pm.resolveWithContext(h, nil, s.graph)
|
||||
r := pm.resolve(h)
|
||||
if r != nil {
|
||||
resolved[i] = r.Name
|
||||
} else {
|
||||
|
||||
+9
-15
@@ -240,7 +240,6 @@ type TransmissionResp struct {
|
||||
SNR interface{} `json:"snr"`
|
||||
RSSI interface{} `json:"rssi"`
|
||||
PathJSON interface{} `json:"path_json"`
|
||||
ResolvedPath []*string `json:"resolved_path,omitempty"`
|
||||
Direction interface{} `json:"direction"`
|
||||
Score interface{} `json:"score,omitempty"`
|
||||
Observations []ObservationResp `json:"observations,omitempty"`
|
||||
@@ -255,7 +254,6 @@ type ObservationResp struct {
|
||||
SNR interface{} `json:"snr"`
|
||||
RSSI interface{} `json:"rssi"`
|
||||
PathJSON interface{} `json:"path_json"`
|
||||
ResolvedPath []*string `json:"resolved_path,omitempty"`
|
||||
Timestamp interface{} `json:"timestamp"`
|
||||
}
|
||||
|
||||
@@ -875,21 +873,18 @@ type TraceResponse struct {
|
||||
// ─── Resolve Hops ──────────────────────────────────────────────────────────────
|
||||
|
||||
type HopCandidate struct {
|
||||
Name interface{} `json:"name"`
|
||||
Pubkey string `json:"pubkey"`
|
||||
Lat interface{} `json:"lat"`
|
||||
Lon interface{} `json:"lon"`
|
||||
AffinityScore *float64 `json:"affinityScore"`
|
||||
Name interface{} `json:"name"`
|
||||
Pubkey string `json:"pubkey"`
|
||||
Lat interface{} `json:"lat"`
|
||||
Lon interface{} `json:"lon"`
|
||||
}
|
||||
|
||||
type HopResolution struct {
|
||||
Name interface{} `json:"name"`
|
||||
Pubkey interface{} `json:"pubkey,omitempty"`
|
||||
Ambiguous *bool `json:"ambiguous,omitempty"`
|
||||
Candidates []HopCandidate `json:"candidates"`
|
||||
Conflicts []interface{} `json:"conflicts"`
|
||||
BestCandidate *string `json:"bestCandidate,omitempty"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
Name interface{} `json:"name"`
|
||||
Pubkey interface{} `json:"pubkey,omitempty"`
|
||||
Ambiguous *bool `json:"ambiguous,omitempty"`
|
||||
Candidates []HopCandidate `json:"candidates"`
|
||||
Conflicts []interface{} `json:"conflicts"`
|
||||
}
|
||||
|
||||
type ResolveHopsResponse struct {
|
||||
@@ -926,7 +921,6 @@ type ClientConfigResponse struct {
|
||||
ExternalUrls interface{} `json:"externalUrls"`
|
||||
PropagationBufferMs float64 `json:"propagationBufferMs"`
|
||||
Timestamps TimestampConfig `json:"timestamps"`
|
||||
DebugAffinity bool `json:"debugAffinity,omitempty"`
|
||||
}
|
||||
|
||||
// ─── IATA Coords ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# CoreScope v3.4 Release Notes
|
||||
|
||||
**The neighbor affinity release.** CoreScope now understands how nodes relate to each other — not just that they exist, but how strongly they're connected. This powers smarter hop resolution, richer node detail pages, and a new graph visualization in analytics.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
### Neighbor Affinity System (7 milestones)
|
||||
A complete neighbor relationship engine, from backend graph building to frontend visualization:
|
||||
|
||||
- **Affinity graph builder** — computes neighbor relationships and connection strength from packet traffic (#507)
|
||||
- **Affinity API endpoints** — REST endpoints to query neighbor data (#508)
|
||||
- **Show Neighbors via affinity API** — the existing Show Neighbors feature now uses real affinity data instead of raw packet heuristics (#512, fixes #484)
|
||||
- **Affinity-aware hop resolution** — hop resolver uses neighbor affinity to pick better paths (#511)
|
||||
- **Node detail neighbors section** — dedicated neighbors panel on the node detail page (#510)
|
||||
- **Affinity debugging tools** — inspect and troubleshoot affinity calculations (#521)
|
||||
- **Neighbor graph visualization** — interactive neighbor graph in the analytics tab (#513)
|
||||
|
||||
### Customizer v2
|
||||
- Event-driven state management replaces the old imperative approach — cleaner, more predictable theme/config updates (#503)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- **Stale parsed cache on observation packets** — observation packets now correctly invalidate the JSON parse cache (#505)
|
||||
- **Null-guard rAF callbacks** — live page no longer crashes when `requestAnimationFrame` callbacks fire after cleanup (#506)
|
||||
- **Customizer v2 phantom overrides** — fixed phantom config entries, missing defaults, and stale dark mode state (#520)
|
||||
- **Neighbor affinity empty results** — fixed pubKey field name mismatch causing empty affinity graphs (#524)
|
||||
- **Home defaults in server theme** — server-side theme config now includes home page defaults (#526)
|
||||
- **Neighbor UI crash + dark mode** — fixed Show Neighbors crash and improved dark mode contrast (#527)
|
||||
- **Home page steps + FAQ** — both steps AND FAQ now render correctly on the home page (#529)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
- **Cached JSON.parse for packet data** — packet payloads are parsed once and cached, avoiding redundant `JSON.parse` calls on repeated access (#400)
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Affinity graph scales with traffic volume** — networks with very low packet rates may show weak or missing neighbor relationships until enough data accumulates
|
||||
- **Debugging tools are developer-facing** — the affinity debug panel (#521) is functional but not polished for end-user consumption
|
||||
- **Customizer v2 migration** — custom themes saved under v1 may need to be re-applied after upgrade
|
||||
@@ -1,568 +0,0 @@
|
||||
# Customizer Rework Spec
|
||||
|
||||
## Overview
|
||||
|
||||
The current customizer (`public/customize.js`) suffers from fundamental state management issues documented in [issue #284](https://github.com/Kpa-clawbot/CoreScope/issues/284). State is scattered across 7 localStorage keys, CSS updates bypass the data layer, and there's no single source of truth for the effective configuration.
|
||||
|
||||
This spec defines a clean rework based on event-driven state management with a single data flow path. The goal: predictable state, minimal storage footprint, portable config format, and zero ambiguity about which values are active and why.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
These are agreed and final. Do not reinterpret or deviate.
|
||||
|
||||
1. **Three state layers:** server defaults (immutable after fetch), user overrides (delta in localStorage), effective config (computed via merge, never stored directly).
|
||||
2. **Single data flow:** user action → debounce (~300ms) → write delta to localStorage → read back from localStorage → merge with server defaults → apply CSS variables. No shortcuts, no optimistic CSS updates (see Decision #12 for the one exception).
|
||||
3. **One localStorage key:** `cs-theme-overrides` — replaces the current 7 scattered keys (`meshcore-user-theme`, `meshcore-timestamp-mode`, `meshcore-timestamp-timezone`, `meshcore-timestamp-format`, `meshcore-timestamp-custom-format`, `meshcore-heatmap-opacity`, `meshcore-live-heatmap-opacity`).
|
||||
4. **Universal format:** same shape as the server's `ThemeResponse` plus additional keys. Works identically for user export, admin `theme.json`, and user import.
|
||||
5. **User overrides always win** in merge — `merge(serverDefaults, userOverrides)` = effective config.
|
||||
6. **Override indicator:** shown in customizer panel ONLY when override value differs from current server default.
|
||||
7. **No silent pruning:** overrides stay in localStorage until the user explicitly resets them (per-field reset or full reset). The delta may contain values that happen to match current server defaults — that's fine. User intent is preserved; nothing silently disappears.
|
||||
8. **Per-field reset:** remove a single key from the delta → re-merge → re-apply CSS.
|
||||
9. **Full reset:** `localStorage.removeItem('cs-theme-overrides')` → re-merge (effective = server defaults) → re-apply CSS.
|
||||
10. **Export = dump delta object as JSON download. Import = validate shape, write to localStorage, trigger re-merge.**
|
||||
11. **No CSS magic:** CSS variables ONLY update after the localStorage round-trip completes. No optimistic updates (see Decision #12 for the one exception).
|
||||
12. **Color picker optimistic CSS exception:** For continuous inputs (color pickers, sliders), CSS is updated optimistically during `input` events for visual responsiveness. The localStorage write only happens on `change` event (mouseup/blur). On `change`, the full pipeline runs: write → read → merge → apply (which will match the optimistic state). If the user refreshes mid-drag before `change` fires, the change is lost — this is acceptable. This is the ONLY exception to the localStorage-first rule.
|
||||
|
||||
## Dark/Light Mode
|
||||
|
||||
The customizer treats light and dark mode as separate override sections:
|
||||
|
||||
- **`theme`** stores light mode color overrides.
|
||||
- **`themeDark`** stores dark mode color overrides.
|
||||
- When the user changes a color in the customizer, it writes to whichever section matches their current mode: `theme` if light, `themeDark` if dark.
|
||||
- The dark/light mode toggle preference (`meshcore-theme` localStorage key) is **separate** from the delta object. It is a view preference, not a customization — it is not stored in `cs-theme-overrides`.
|
||||
- The customizer UI shows color fields for the currently active mode only. Switching modes re-renders the color fields with values from the matching section.
|
||||
|
||||
## Presets
|
||||
|
||||
The existing preset themes are preserved and flow through the standard pipeline:
|
||||
|
||||
**Available presets:** Default, Ocean, Forest, Sunset, Monochrome.
|
||||
|
||||
**How presets work:**
|
||||
- Clicking a preset writes its values to localStorage via the same pipeline as any other change: preset data → `writeOverrides()` → read back → merge → apply CSS.
|
||||
- Presets are NOT special — they are pre-built delta objects applied through the standard flow.
|
||||
- Each preset contains both `theme` (light) and `themeDark` (dark) sections, plus any other overrides the preset defines (e.g., `nodeColors`).
|
||||
- **"Reset to Default"** = clear all overrides (equivalent to full reset: `localStorage.removeItem('cs-theme-overrides')` → re-merge → apply).
|
||||
|
||||
**Preset data format:** Same shape as the delta object. Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": {
|
||||
"accent": "#0077b6",
|
||||
"navBg": "#03045e",
|
||||
"background": "#f0f7fa"
|
||||
},
|
||||
"themeDark": {
|
||||
"accent": "#48cae4",
|
||||
"navBg": "#03045e",
|
||||
"background": "#0a1929"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Applying a preset **replaces** the entire delta (it's a `writeOverrides(presetData)`, not a merge onto existing overrides). The user can then further customize individual fields on top.
|
||||
|
||||
## Data Model
|
||||
|
||||
### Delta Object Format
|
||||
|
||||
The user override delta is a sparse object — it only contains fields the user has explicitly changed. The shape mirrors the server's `ThemeResponse` (from `/api/config/theme`) plus additional client-only sections:
|
||||
|
||||
```json
|
||||
{
|
||||
"branding": {
|
||||
"siteName": "string — site name override",
|
||||
"tagline": "string — tagline override",
|
||||
"logoUrl": "string — custom logo URL",
|
||||
"faviconUrl": "string — custom favicon URL"
|
||||
},
|
||||
"theme": {
|
||||
"accent": "string — CSS color, light mode accent",
|
||||
"accentHover": "string — CSS color, light mode accent hover",
|
||||
"navBg": "string — CSS color, nav background",
|
||||
"navBg2": "string — CSS color, nav secondary background",
|
||||
"navText": "string — CSS color, nav text",
|
||||
"navTextMuted": "string — CSS color, nav muted text",
|
||||
"background": "string — CSS color, page background",
|
||||
"text": "string — CSS color, body text",
|
||||
"textMuted": "string — CSS color, muted text",
|
||||
"border": "string — CSS color, borders",
|
||||
"surface1": "string — CSS color, surface level 1",
|
||||
"surface2": "string — CSS color, surface level 2",
|
||||
"cardBg": "string — CSS color, card backgrounds",
|
||||
"contentBg": "string — CSS color, content area background",
|
||||
"detailBg": "string — CSS color, detail pane background",
|
||||
"inputBg": "string — CSS color, input backgrounds",
|
||||
"rowStripe": "string — CSS color, alternating row stripe",
|
||||
"rowHover": "string — CSS color, row hover highlight",
|
||||
"selectedBg": "string — CSS color, selected row background",
|
||||
"statusGreen": "string — CSS color, healthy status",
|
||||
"statusYellow": "string — CSS color, degraded status",
|
||||
"statusRed": "string — CSS color, critical status",
|
||||
"font": "string — CSS font-family for body text",
|
||||
"mono": "string — CSS font-family for monospace"
|
||||
},
|
||||
"themeDark": {
|
||||
"/* same keys as theme — dark mode overrides */"
|
||||
},
|
||||
"nodeColors": {
|
||||
"repeater": "string — CSS color",
|
||||
"companion": "string — CSS color",
|
||||
"room": "string — CSS color",
|
||||
"sensor": "string — CSS color",
|
||||
"observer": "string — CSS color"
|
||||
},
|
||||
"typeColors": {
|
||||
"ADVERT": "string — CSS color",
|
||||
"GRP_TXT": "string — CSS color",
|
||||
"TXT_MSG": "string — CSS color",
|
||||
"ACK": "string — CSS color",
|
||||
"REQUEST": "string — CSS color",
|
||||
"RESPONSE": "string — CSS color",
|
||||
"TRACE": "string — CSS color",
|
||||
"PATH": "string — CSS color",
|
||||
"ANON_REQ": "string — CSS color"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "string",
|
||||
"heroSubtitle": "string",
|
||||
"steps": "[array of {emoji, title, description}]",
|
||||
"checklist": "[array of strings]",
|
||||
"footerLinks": "[array of {label, url}]"
|
||||
},
|
||||
"timestamps": {
|
||||
"defaultMode": "string — 'ago' | 'absolute'",
|
||||
"timezone": "string — 'local' | 'utc'",
|
||||
"formatPreset": "string — 'iso' | 'iso-seconds' | 'locale'",
|
||||
"customFormat": "string — custom strftime-style format"
|
||||
},
|
||||
"heatmapOpacity": "number — 0.0 to 1.0",
|
||||
"liveHeatmapOpacity": "number — 0.0 to 1.0"
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- All sections and keys are optional. An empty object `{}` means "no overrides."
|
||||
- The `timestamps`, `heatmapOpacity`, and `liveHeatmapOpacity` keys are client-only extensions — not part of the server's `ThemeResponse`, but included in the universal format for portability.
|
||||
|
||||
### localStorage Key
|
||||
|
||||
**Key:** `cs-theme-overrides`
|
||||
**Value:** JSON string of the delta object above.
|
||||
**Absent key** = no overrides = effective config equals server defaults.
|
||||
|
||||
### Dark/Light Mode Preference
|
||||
|
||||
**Key:** `meshcore-theme`
|
||||
**Value:** `"dark"` or `"light"` (or absent = follow system preference).
|
||||
**This key is NOT part of the delta object.** It controls which mode is active, not which colors are used. The delta stores overrides for both modes independently in `theme` and `themeDark`.
|
||||
|
||||
## Data Flow Diagrams
|
||||
|
||||
### Page Load
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Fetch │ │ Read localStorage │ │ Migration check │
|
||||
│ /api/config/ │ │ cs-theme-overrides│ │ (one-time) │
|
||||
│ theme │ └────────┬─────────┘ └────────┬────────┘
|
||||
└──────┬──────┘ │ │
|
||||
│ │ ┌────────────────────┘
|
||||
▼ ▼ ▼
|
||||
serverDefaults userOverrides (possibly migrated)
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ computeEffective(server, userOverrides) │
|
||||
└──────────────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ window.SITE_CONFIG = effective │ ← atomic assignment
|
||||
└──────────────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ applyCSS(effective) │ ← sets CSS vars on :root for current mode
|
||||
└──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ dispatch 'theme-changed' │ ← bare signal, no payload
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### User Change (e.g., picks new accent color)
|
||||
|
||||
```
|
||||
User action (input/click)
|
||||
│
|
||||
▼
|
||||
debounce(300ms)
|
||||
│
|
||||
▼
|
||||
setOverride('theme', 'accent', '#ff0000')
|
||||
│
|
||||
├─► readOverrides() ← read current delta from localStorage
|
||||
│ │
|
||||
│ ▼
|
||||
├─► update delta object ← set delta.theme.accent = '#ff0000'
|
||||
│ │
|
||||
│ ▼
|
||||
├─► writeOverrides(delta) ← serialize & write to localStorage
|
||||
│ │
|
||||
│ ▼
|
||||
├─► readOverrides() ← read BACK from localStorage (round-trip)
|
||||
│ │
|
||||
│ ▼
|
||||
├─► computeEffective(server, delta)
|
||||
│ │
|
||||
│ ▼
|
||||
├─► window.SITE_CONFIG = effective ← atomic assignment
|
||||
│ │
|
||||
│ ▼
|
||||
└─► applyCSS(effective) ← CSS vars updated on :root
|
||||
│
|
||||
▼
|
||||
dispatch 'theme-changed'
|
||||
```
|
||||
|
||||
**Color picker / slider exception:** During continuous `input` events (drag), CSS is updated optimistically (directly setting `--var` on `:root`) without the localStorage round-trip. The full pipeline above only runs on the `change` event (mouseup/blur).
|
||||
|
||||
### Per-Field Reset
|
||||
|
||||
```
|
||||
User clicks reset icon on a field
|
||||
│
|
||||
▼
|
||||
clearOverride('theme', 'accent')
|
||||
│
|
||||
├─► readOverrides()
|
||||
├─► delete delta.theme.accent
|
||||
├─► if delta.theme is empty, delete delta.theme
|
||||
├─► writeOverrides(delta)
|
||||
├─► readOverrides() ← round-trip
|
||||
├─► computeEffective(server, delta)
|
||||
├─► window.SITE_CONFIG = effective
|
||||
└─► applyCSS(effective)
|
||||
│
|
||||
▼
|
||||
dispatch 'theme-changed'
|
||||
```
|
||||
|
||||
### Full Reset
|
||||
|
||||
```
|
||||
User clicks "Reset All"
|
||||
│
|
||||
▼
|
||||
localStorage.removeItem('cs-theme-overrides')
|
||||
│
|
||||
▼
|
||||
computeEffective(server, {}) ← no overrides = server defaults
|
||||
│
|
||||
▼
|
||||
window.SITE_CONFIG = effective
|
||||
│
|
||||
▼
|
||||
applyCSS(effective)
|
||||
│
|
||||
▼
|
||||
dispatch 'theme-changed'
|
||||
```
|
||||
|
||||
### Export
|
||||
|
||||
```
|
||||
User clicks "Export"
|
||||
│
|
||||
▼
|
||||
readOverrides()
|
||||
│
|
||||
▼
|
||||
JSON.stringify(delta, null, 2)
|
||||
│
|
||||
▼
|
||||
trigger download as .json file
|
||||
```
|
||||
|
||||
### Import
|
||||
|
||||
```
|
||||
User selects .json file
|
||||
│
|
||||
▼
|
||||
parse JSON
|
||||
│
|
||||
▼
|
||||
validateShape(parsed) ← check structure, validate values
|
||||
│
|
||||
├─► invalid → show error, abort
|
||||
│
|
||||
▼ valid
|
||||
writeOverrides(parsed)
|
||||
│
|
||||
▼
|
||||
readOverrides() ← round-trip
|
||||
│
|
||||
▼
|
||||
computeEffective(server, delta)
|
||||
│
|
||||
▼
|
||||
window.SITE_CONFIG = effective
|
||||
│
|
||||
▼
|
||||
applyCSS(effective)
|
||||
│
|
||||
▼
|
||||
dispatch 'theme-changed'
|
||||
```
|
||||
|
||||
## Function Signatures
|
||||
|
||||
### `readOverrides() → object`
|
||||
|
||||
Reads `cs-theme-overrides` from localStorage, parses as JSON. Returns empty object `{}` on missing key, parse error, or non-object value. Never throws.
|
||||
|
||||
### `writeOverrides(delta: object) → void`
|
||||
|
||||
Serializes `delta` to JSON and writes to `cs-theme-overrides` in localStorage. If `delta` is empty (`{}`), removes the key entirely.
|
||||
|
||||
**Validation on write:**
|
||||
- Color values must match: `#hex` (3, 4, 6, or 8 digit), `rgb()`, `rgba()`, `hsl()`, `hsla()`, or CSS named colors. Invalid color values are rejected (not written) with `console.warn`.
|
||||
- Numeric values (`heatmapOpacity`, `liveHeatmapOpacity`) must be finite numbers in the range 0–1. Invalid values are rejected with `console.warn`.
|
||||
- Timestamp enum values are validated against known options (`defaultMode`: `'ago'`/`'absolute'`; `timezone`: `'local'`/`'utc'`; `formatPreset`: `'iso'`/`'iso-seconds'`/`'locale'`). Invalid values are rejected with `console.warn`.
|
||||
|
||||
**Quota error handling:**
|
||||
- Wrap `localStorage.setItem` in try/catch.
|
||||
- On `QuotaExceededError`: show a visible warning to the user ("Storage full — changes may not be saved"), log to console.
|
||||
- Do NOT silently swallow the error.
|
||||
|
||||
### `computeEffective(serverConfig: object, userOverrides: object) → object`
|
||||
|
||||
Deep merges `userOverrides` onto `serverConfig`. For each section (e.g., `theme`, `nodeColors`), if `userOverrides` has the section, its keys override the corresponding `serverConfig` keys. Top-level non-object keys (e.g., `heatmapOpacity`) are directly overridden.
|
||||
|
||||
Returns a new object — neither input is mutated.
|
||||
|
||||
**Merge rules:**
|
||||
- Object sections: shallow merge per section (`Object.assign({}, server.theme, user.theme)`)
|
||||
- Array sections (e.g., `home.steps`): full replacement (user array wins entirely, no element-level merge)
|
||||
- Scalar sections (e.g., `heatmapOpacity`): direct replacement
|
||||
|
||||
After computing the effective config, writes it to `window.SITE_CONFIG` atomically (single assignment, not piecemeal mutations).
|
||||
|
||||
### `applyCSS(effectiveConfig: object) → void`
|
||||
|
||||
Maps effective config values to CSS custom properties on `:root`. Behavior:
|
||||
|
||||
1. Reads the current mode (light/dark) from the `meshcore-theme` localStorage key, falling back to system preference (`prefers-color-scheme`).
|
||||
2. Applies the matching section's values: `theme` for light mode, `themeDark` for dark mode.
|
||||
3. Also applies mode-independent values: node colors as `--node-{role}`, type colors as `--type-{name}`, font families as `--font-body` and `--font-mono`.
|
||||
4. Does NOT generate dual CSS rule blocks — only the current mode's values are applied to `:root`.
|
||||
5. On dark/light mode toggle, `applyCSS` is called again to re-apply the correct section.
|
||||
|
||||
Updates the `<style>` element (create if absent, reuse if present). Dispatches a `theme-changed` CustomEvent on `window` after applying.
|
||||
|
||||
### `theme-changed` Event
|
||||
|
||||
- `theme-changed` is a bare `CustomEvent` with no payload (matches current behavior).
|
||||
- After each merge cycle, the effective config is written to `window.SITE_CONFIG` atomically (single assignment).
|
||||
- `window.SITE_CONFIG` is the canonical readable source for effective config throughout the app. All existing listeners that read from `SITE_CONFIG` continue to work without changes.
|
||||
|
||||
### `setOverride(section: string, key: string, value: any) → void`
|
||||
|
||||
Sets a single override. For nested sections (e.g., `section='theme'`, `key='accent'`), sets `delta[section][key] = value`. For top-level scalars (e.g., `section=null`, `key='heatmapOpacity'`), sets `delta[key] = value`.
|
||||
|
||||
Follows the full data flow: read → update → write → read-back → merge → apply CSS → dispatch `theme-changed`. Debounced at ~300ms (the debounce wraps the write-through-to-CSS portion).
|
||||
|
||||
### `clearOverride(section: string, key: string) → void`
|
||||
|
||||
Removes a single key from the delta. If the section becomes empty after removal, removes the section too. Triggers the full data flow (no debounce — resets should feel instant).
|
||||
|
||||
### `migrateOldKeys() → object | null`
|
||||
|
||||
One-time migration. Checks for any of the 7 legacy localStorage keys. If found:
|
||||
1. Reads all legacy values
|
||||
2. Maps them into the new delta format (see Migration Plan)
|
||||
3. Writes the merged delta to `cs-theme-overrides`
|
||||
4. Removes all 7 legacy keys
|
||||
5. Returns the migrated delta
|
||||
|
||||
Returns `null` if no legacy keys found.
|
||||
|
||||
### `validateShape(obj: any) → { valid: boolean, errors: string[] }`
|
||||
|
||||
Validates that an imported object conforms to the expected shape:
|
||||
- Must be a plain object
|
||||
- Top-level keys must be from the known set: `branding`, `theme`, `themeDark`, `nodeColors`, `typeColors`, `home`, `timestamps`, `heatmapOpacity`, `liveHeatmapOpacity`
|
||||
- Section values must be objects (where expected) or correct scalar types
|
||||
- Color values are validated: must match `#hex` (3, 4, 6, or 8 digit), `rgb()`, `rgba()`, `hsl()`, `hsla()`, or CSS named colors
|
||||
- Numeric values (`heatmapOpacity`, `liveHeatmapOpacity`) must be finite numbers in range 0–1
|
||||
- Timestamp enum values validated against known options
|
||||
|
||||
Unknown top-level keys cause a warning but don't fail validation (forward compatibility).
|
||||
|
||||
## Migration Plan
|
||||
|
||||
On first page load, before the normal init flow:
|
||||
|
||||
1. Check if `cs-theme-overrides` already exists → if yes, skip migration.
|
||||
2. Check if ANY of the 7 legacy keys exist in localStorage.
|
||||
3. If legacy keys found, build a delta object using the exact mapping below:
|
||||
|
||||
### Field-by-Field Migration Mapping
|
||||
|
||||
```
|
||||
meshcore-user-theme (JSON) → parse, map directly:
|
||||
.branding → delta.branding
|
||||
.theme → delta.theme
|
||||
.themeDark → delta.themeDark
|
||||
.nodeColors → delta.nodeColors
|
||||
.typeColors → delta.typeColors
|
||||
.home → delta.home
|
||||
(any other keys are dropped)
|
||||
|
||||
meshcore-timestamp-mode → delta.timestamps.defaultMode
|
||||
meshcore-timestamp-timezone → delta.timestamps.timezone
|
||||
meshcore-timestamp-format → delta.timestamps.formatPreset
|
||||
meshcore-timestamp-custom-format → delta.timestamps.customFormat
|
||||
meshcore-heatmap-opacity → delta.heatmapOpacity (parseFloat)
|
||||
meshcore-live-heatmap-opacity → delta.liveHeatmapOpacity (parseFloat)
|
||||
```
|
||||
|
||||
4. Write the assembled delta to `cs-theme-overrides`.
|
||||
5. Delete all 7 legacy keys.
|
||||
6. Continue with normal init.
|
||||
|
||||
**Edge cases:**
|
||||
- If `meshcore-user-theme` contains invalid JSON, skip it (log a warning to console).
|
||||
- If a legacy value is empty string or null, skip that field.
|
||||
- Migration runs exactly once — the presence of `cs-theme-overrides` (even as `{}`) prevents re-migration.
|
||||
|
||||
## `allowCustomFormat` — User Preferences Trump
|
||||
|
||||
The server-side `allowCustomFormat` gate is not enforced client-side. If a user imports a delta with a custom format, it's applied regardless. The server controls what formats are available in the UI (whether the custom format input field is shown), but does not block stored preferences.
|
||||
|
||||
## Override Indicator UX
|
||||
|
||||
In the customizer panel, each field that has an active override (value differs from server default) shows a visual indicator:
|
||||
|
||||
- **Indicator:** A small dot or icon (e.g., `●` or a reset arrow `↺`) adjacent to the field label.
|
||||
- **Color:** Use the accent color to draw attention without being noisy.
|
||||
- **Behavior:** Clicking the indicator resets that single field (calls `clearOverride`).
|
||||
- **Tooltip:** "Reset to server default" or "This value differs from the server default."
|
||||
- **Absence:** Fields matching the server default show no indicator — clean and minimal.
|
||||
|
||||
**Section-level indicator:** If any field in a section (e.g., "Theme Colors") is overridden, the tab/section header shows a count badge (e.g., "Theme Colors (3)").
|
||||
|
||||
**"Reset All" button:** Always visible at bottom of panel. Confirms before executing (`localStorage.removeItem` + re-merge).
|
||||
|
||||
## UX Requirements
|
||||
|
||||
### Browser-Local Banner
|
||||
|
||||
The customizer panel must display a persistent, always-visible notice:
|
||||
|
||||
> **"These settings are saved in your browser only and don't affect other users."**
|
||||
|
||||
This is NOT a tooltip, NOT a dismissible popup — it must be always visible in the panel header or footer area. Users must understand at a glance that their changes are local.
|
||||
|
||||
### Auto-Save Indicator
|
||||
|
||||
Show a persistent status in the customizer panel footer, Google Docs style — subtle but always present:
|
||||
|
||||
- **Default state:** "All changes saved" (muted text)
|
||||
- **During debounce:** "Saving..." (muted text)
|
||||
- **On quota error:** "⚠️ Storage full — changes may not be saved" (red text, persistent until resolved)
|
||||
|
||||
The indicator reflects the actual state of the localStorage write, not just the UI action.
|
||||
|
||||
## Server Compatibility
|
||||
|
||||
The delta format is intentionally shaped to be a valid subset of the server's `theme.json` admin config file. This means:
|
||||
|
||||
- **User export → admin import:** An admin can take a user's exported JSON and drop it into `theme.json` as server defaults. The `timestamps`, `heatmapOpacity`, and `liveHeatmapOpacity` keys are ignored by the current server (it doesn't read them from `theme.json`), but they don't cause errors.
|
||||
- **Admin config → user import:** A `theme.json` file can be imported as user overrides. Unknown server-only keys are ignored by the client.
|
||||
- **Round-trip safe:** Export → import produces identical delta (assuming no server default changes between operations).
|
||||
|
||||
The server's `ThemeResponse` struct currently returns: `branding`, `theme`, `themeDark`, `nodeColors`, `typeColors`, `home`. The client-only extensions (`timestamps`, `heatmapOpacity`, `liveHeatmapOpacity`) are additive — they extend the format without conflicting.
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests (Node.js, no browser required)
|
||||
|
||||
1. **`readOverrides`**
|
||||
- Returns `{}` when key is absent
|
||||
- Returns `{}` when key contains invalid JSON
|
||||
- Returns `{}` when key contains a non-object (string, array, number)
|
||||
- Returns parsed object when key contains valid JSON object
|
||||
|
||||
2. **`writeOverrides`**
|
||||
- Writes serialized JSON to localStorage
|
||||
- Removes key when delta is empty `{}`
|
||||
- Round-trips correctly (write → read = identical object)
|
||||
- Rejects invalid color values with console.warn
|
||||
- Rejects out-of-range numeric values with console.warn
|
||||
- Rejects invalid timestamp enum values with console.warn
|
||||
- Handles QuotaExceededError gracefully (warns user, does not throw)
|
||||
|
||||
3. **`computeEffective`**
|
||||
- Returns server defaults when overrides is `{}`
|
||||
- Overrides a single key in a section
|
||||
- Overrides multiple keys across sections
|
||||
- Does not mutate either input
|
||||
- Handles missing sections in overrides gracefully
|
||||
- Array values (e.g., `home.steps`) are fully replaced, not merged
|
||||
- Top-level scalars (`heatmapOpacity`) are directly replaced
|
||||
|
||||
4. **`setOverride` / `clearOverride`**
|
||||
- Setting a value stores it in the delta
|
||||
- Clearing a key removes it from delta
|
||||
- Clearing the last key in a section removes the section
|
||||
- Full data flow executes (CSS vars updated)
|
||||
|
||||
5. **`migrateOldKeys`**
|
||||
- Migrates all 7 keys correctly using exact field mapping
|
||||
- Handles partial migration (only some keys present)
|
||||
- Handles invalid JSON in `meshcore-user-theme`
|
||||
- Removes all legacy keys after migration
|
||||
- Skips migration if `cs-theme-overrides` already exists
|
||||
- Returns null when no legacy keys found
|
||||
- Drops unknown keys from `meshcore-user-theme`
|
||||
|
||||
6. **`validateShape`**
|
||||
- Accepts valid delta objects
|
||||
- Accepts empty object
|
||||
- Rejects non-objects (string, array, null)
|
||||
- Warns on unknown top-level keys (doesn't reject)
|
||||
- Validates section types (object vs scalar)
|
||||
- Rejects invalid color values
|
||||
- Rejects out-of-range opacity values
|
||||
- Rejects invalid timestamp enum values
|
||||
|
||||
### Browser/E2E Tests (Playwright)
|
||||
|
||||
1. **Customizer opens and shows current values** — fields reflect effective config.
|
||||
2. **Changing a color updates CSS variable** — after debounce, `:root` has new value.
|
||||
3. **Override indicator appears** when value differs from server default.
|
||||
4. **Per-field reset** removes override, reverts to server default, indicator disappears.
|
||||
5. **Full reset** clears all overrides, all fields show server defaults.
|
||||
6. **Export** downloads a JSON file with current delta.
|
||||
7. **Import** applies overrides from uploaded JSON file.
|
||||
8. **Migration** — set legacy keys, reload, verify they're migrated and removed.
|
||||
9. **Preset application** — clicking a preset applies its colors, fields update.
|
||||
10. **Dark/light mode toggle** — switching mode re-applies correct section's CSS vars.
|
||||
11. **Browser-local banner** — verify persistent notice is visible in customizer panel.
|
||||
12. **Auto-save indicator** — verify status text updates during and after changes.
|
||||
|
||||
## What's NOT In Scope
|
||||
|
||||
- **Undo/redo stack** — could be added as P2. For v1, per-field reset to server default is the only revert mechanism.
|
||||
- **Cross-tab synchronization** — two tabs editing simultaneously may clobber each other's changes. Acceptable for v1.
|
||||
- **Server-side timestamp config** (`allowCustomFormat` gate) — remains server-only, not exposed in the customizer delta. The server controls UI availability but does not block stored preferences (see `allowCustomFormat` section above).
|
||||
- **Admin import endpoint** — no server API for uploading `theme.json` via the UI. Admins edit the file directly. Future work.
|
||||
- **Map config overrides** (`mapDefaults.center`, `mapDefaults.zoom`) — separate concern, not part of theme. Future work.
|
||||
- **Geo-filter config** — server-only. Not in scope.
|
||||
- **Per-page layout preferences** (column widths, sort orders) — separate from theming. Future work.
|
||||
+1
-484
@@ -85,7 +85,6 @@
|
||||
<button class="tab-btn" data-tab="subpaths">Route Patterns</button>
|
||||
<button class="tab-btn" data-tab="nodes">Nodes</button>
|
||||
<button class="tab-btn" data-tab="distance">Distance</button>
|
||||
<button class="tab-btn" data-tab="neighbor-graph">Neighbor Graph</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analyticsContent" class="analytics-content">
|
||||
@@ -172,7 +171,6 @@
|
||||
case 'subpaths': await renderSubpaths(el); break;
|
||||
case 'nodes': await renderNodesTab(el); break;
|
||||
case 'distance': await renderDistanceTab(el); break;
|
||||
case 'neighbor-graph': await renderNeighborGraphTab(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
requestAnimationFrame(() => {
|
||||
@@ -267,37 +265,6 @@
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Affinity stats widget — fetch and append if debugAffinity enabled
|
||||
var showDebug = (window.CLIENT_CONFIG && window.CLIENT_CONFIG.debugAffinity) || localStorage.getItem('meshcore-affinity-debug') === 'true';
|
||||
if (showDebug) {
|
||||
var apiKey = localStorage.getItem('meshcore-api-key') || '';
|
||||
fetch('/api/debug/affinity', { headers: { 'X-API-Key': apiKey } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data || !data.stats) return;
|
||||
var s = data.stats;
|
||||
var total = s.resolvedCount + s.ambiguousCount + s.unresolvedCount;
|
||||
var resolvedPct = total > 0 ? (s.resolvedCount / total * 100).toFixed(1) : '0.0';
|
||||
var ambiguousPct = total > 0 ? (s.ambiguousCount / total * 100).toFixed(1) : '0.0';
|
||||
var widget = document.createElement('div');
|
||||
widget.className = 'analytics-row';
|
||||
widget.innerHTML = '<div class="analytics-card flex-1">' +
|
||||
'<h3>🔍 Neighbor Affinity Graph</h3>' +
|
||||
'<div class="stats-grid">' +
|
||||
'<div class="stat-card"><div class="stat-value">' + s.totalEdges + '</div><div class="stat-label">Total Edges</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-value">' + s.totalNodes + '</div><div class="stat-label">Total Nodes</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-value">' + s.resolvedCount + ' <span style="font-size:12px;color:var(--text-muted)">(' + resolvedPct + '%)</span></div><div class="stat-label">Resolved Prefixes</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-value">' + s.ambiguousCount + ' <span style="font-size:12px;color:var(--text-muted)">(' + ambiguousPct + '%)</span></div><div class="stat-label">Ambiguous Prefixes</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-value">' + (s.avgConfidence || 0).toFixed(3) + '</div><div class="stat-label">Avg Confidence</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-value">' + (s.coldStartCoverage || 0).toFixed(1) + '%</div><div class="stat-label">Cold-Start Coverage</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-value">' + (s.cacheAge || 'N/A') + '</div><div class="stat-label">Cache Age</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-value">' + (s.lastRebuild ? s.lastRebuild.substring(0, 19) : 'N/A') + '</div><div class="stat-label">Last Rebuild</div></div>' +
|
||||
'</div></div>';
|
||||
el.appendChild(widget);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderPayloadPie(types) {
|
||||
@@ -1832,7 +1799,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; }
|
||||
function destroy() { _analyticsData = {}; _channelData = null; }
|
||||
|
||||
// Expose for testing
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1843,455 +1810,5 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
window._analyticsChannelTheadHtml = channelTheadHtml;
|
||||
}
|
||||
|
||||
// ─── Neighbor Graph Tab ─────────────────────────────────────────────────────
|
||||
|
||||
let _ngState = null; // neighbor graph state
|
||||
|
||||
async function renderNeighborGraphTab(el) {
|
||||
el.innerHTML = `
|
||||
<div class="analytics-card" id="ngCard">
|
||||
<h3>🕸️ Neighbor Graph</h3>
|
||||
<div id="ngFilters" class="ng-filters" style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:12px">
|
||||
<label style="font-size:13px">Roles:
|
||||
<span id="ngRoleChecks" style="margin-left:4px"></span>
|
||||
</label>
|
||||
<label style="font-size:13px">Min Score: <input type="range" id="ngMinScore" min="0" max="100" value="10" style="width:100px;vertical-align:middle">
|
||||
<span id="ngMinScoreVal">0.10</span>
|
||||
</label>
|
||||
<label style="font-size:13px">Confidence:
|
||||
<select id="ngConfidence" style="font-size:12px;padding:2px 4px">
|
||||
<option value="all">Show All</option>
|
||||
<option value="high">High Only</option>
|
||||
<option value="hide-ambiguous">Hide Ambiguous</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="ngStats" class="stat-row" style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px"></div>
|
||||
<div style="position:relative;border:1px solid var(--border);border-radius:6px;overflow:hidden">
|
||||
<canvas id="ngCanvas" width="900" height="600" style="width:100%;height:600px;cursor:grab;outline-offset:2px" role="img" aria-label="Neighbor affinity graph visualization — interactive force-directed network topology" tabindex="0"></canvas>
|
||||
<div id="ngTooltip" style="position:absolute;display:none;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;z-index:10;box-shadow:0 2px 8px rgba(0,0,0,0.2)"></div>
|
||||
</div>
|
||||
<details id="ngAccessibleList" style="margin-top:12px">
|
||||
<summary style="cursor:pointer;font-size:13px;color:var(--text-secondary)">📋 Text-based neighbor list (accessible alternative)</summary>
|
||||
<div id="ngTextList" style="font-size:12px;max-height:300px;overflow-y:auto;padding:8px;background:var(--bg-secondary);border-radius:4px;margin-top:4px"></div>
|
||||
</details>
|
||||
</div>`;
|
||||
|
||||
// Role checkboxes
|
||||
const roles = ['repeater','companion','room','sensor'];
|
||||
const rcEl = document.getElementById('ngRoleChecks');
|
||||
roles.forEach(r => {
|
||||
const color = (window.ROLE_COLORS || {})[r] || '#888';
|
||||
rcEl.innerHTML += `<label style="font-size:12px;margin-right:8px"><input type="checkbox" data-role="${r}" checked> <span style="color:${esc(color)}">${esc(r)}</span></label>`;
|
||||
});
|
||||
|
||||
// Load data
|
||||
const rqs = RegionFilter.regionQueryString();
|
||||
const sep = rqs ? '?' + rqs.slice(1) : '';
|
||||
let graphData;
|
||||
try {
|
||||
graphData = await api('/analytics/neighbor-graph' + sep + (sep ? '&' : '?') + 'min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF });
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="analytics-card"><p class="text-muted">Failed to load neighbor graph: ${esc(e.message)}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
_ngState = createGraphState(graphData);
|
||||
renderNGStats(_ngState);
|
||||
startGraphRenderer();
|
||||
|
||||
// Filter listeners
|
||||
document.getElementById('ngMinScore').addEventListener('input', function() {
|
||||
document.getElementById('ngMinScoreVal').textContent = (this.value / 100).toFixed(2);
|
||||
applyNGFilters();
|
||||
});
|
||||
document.getElementById('ngConfidence').addEventListener('change', applyNGFilters);
|
||||
rcEl.addEventListener('change', applyNGFilters);
|
||||
}
|
||||
|
||||
function createGraphState(data) {
|
||||
const nodes = (data.nodes || []).map((n, i) => ({
|
||||
...n,
|
||||
x: 450 + (Math.random() - 0.5) * 400,
|
||||
y: 300 + (Math.random() - 0.5) * 300,
|
||||
vx: 0, vy: 0,
|
||||
radius: Math.max(6, Math.min(18, 6 + (n.neighbor_count || 0)))
|
||||
}));
|
||||
const nodeIdx = {};
|
||||
nodes.forEach((n, i) => { nodeIdx[n.pubkey] = i; });
|
||||
const edges = (data.edges || []).filter(e => nodeIdx[e.source] !== undefined && nodeIdx[e.target] !== undefined);
|
||||
return {
|
||||
allNodes: nodes, allEdges: edges,
|
||||
nodes, edges, nodeIdx,
|
||||
stats: data.stats || {},
|
||||
zoom: 1, panX: 0, panY: 0,
|
||||
dragging: null, panning: false,
|
||||
lastMouseX: 0, lastMouseY: 0,
|
||||
cooling: 1.0, animId: null
|
||||
};
|
||||
}
|
||||
|
||||
function applyNGFilters() {
|
||||
if (!_ngState) return;
|
||||
const minScore = parseInt(document.getElementById('ngMinScore').value, 10) / 100;
|
||||
const conf = document.getElementById('ngConfidence').value;
|
||||
const checkedRoles = new Set();
|
||||
document.querySelectorAll('#ngRoleChecks input:checked').forEach(cb => checkedRoles.add(cb.dataset.role));
|
||||
|
||||
// Filter nodes by role
|
||||
const visibleNodes = _ngState.allNodes.filter(n => {
|
||||
const role = (n.role || 'unknown').toLowerCase();
|
||||
return checkedRoles.has(role) || role === 'unknown' || role === 'observer';
|
||||
});
|
||||
const visiblePKs = new Set(visibleNodes.map(n => n.pubkey));
|
||||
|
||||
// Filter edges
|
||||
_ngState.edges = _ngState.allEdges.filter(e => {
|
||||
if (e.score < minScore) return false;
|
||||
if (conf === 'high' && (e.ambiguous || e.score < 0.5)) return false;
|
||||
if (conf === 'hide-ambiguous' && e.ambiguous) return false;
|
||||
return visiblePKs.has(e.source) && visiblePKs.has(e.target);
|
||||
});
|
||||
|
||||
// Only include nodes that have at least one visible edge
|
||||
const edgeNodes = new Set();
|
||||
_ngState.edges.forEach(e => { edgeNodes.add(e.source); edgeNodes.add(e.target); });
|
||||
_ngState.nodes = visibleNodes.filter(n => edgeNodes.has(n.pubkey));
|
||||
|
||||
// Rebuild index
|
||||
_ngState.nodeIdx = {};
|
||||
_ngState.nodes.forEach((n, i) => { _ngState.nodeIdx[n.pubkey] = i; });
|
||||
|
||||
_ngState.cooling = 1.0;
|
||||
renderNGStats(_ngState);
|
||||
}
|
||||
|
||||
function renderNGStats(st) {
|
||||
const nodes = st.nodes, edges = st.edges;
|
||||
const totalScore = edges.reduce((s, e) => s + e.score, 0);
|
||||
const avgScore = edges.length ? (totalScore / edges.length) : 0;
|
||||
const ambiguous = edges.filter(e => e.ambiguous).length;
|
||||
const resolved = edges.length ? ((edges.length - ambiguous) / edges.length * 100) : 0;
|
||||
const statsEl = document.getElementById('ngStats');
|
||||
if (!statsEl) return;
|
||||
statsEl.innerHTML = `
|
||||
<div class="stat-card"><div class="stat-value">${nodes.length}</div><div class="stat-label">Nodes</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${edges.length}</div><div class="stat-label">Edges</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${avgScore.toFixed(2)}</div><div class="stat-label">Avg Score</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${resolved.toFixed(0)}%</div><div class="stat-label">Resolved</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${ambiguous}</div><div class="stat-label">Ambiguous</div></div>`;
|
||||
|
||||
// Update canvas aria-label with current graph summary
|
||||
var canvas = document.getElementById('ngCanvas');
|
||||
if (canvas) {
|
||||
canvas.setAttribute('aria-label', 'Neighbor affinity graph: ' + nodes.length + ' nodes, ' + edges.length + ' edges, ' + resolved.toFixed(0) + '% resolved. Use arrow keys to pan, +/- to zoom, 0 to reset.');
|
||||
}
|
||||
|
||||
// Update accessible text list
|
||||
updateNGTextList(st);
|
||||
}
|
||||
|
||||
function updateNGTextList(st) {
|
||||
var listEl = document.getElementById('ngTextList');
|
||||
if (!listEl) return;
|
||||
var nodes = st.nodes, edges = st.edges;
|
||||
if (nodes.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-muted">No nodes to display.</p>';
|
||||
return;
|
||||
}
|
||||
// Build adjacency for text list
|
||||
var adj = {};
|
||||
edges.forEach(function(e) {
|
||||
if (!adj[e.source]) adj[e.source] = [];
|
||||
if (!adj[e.target]) adj[e.target] = [];
|
||||
adj[e.source].push({ pk: e.target, score: e.score, ambiguous: e.ambiguous });
|
||||
adj[e.target].push({ pk: e.source, score: e.score, ambiguous: e.ambiguous });
|
||||
});
|
||||
var nodeMap = {};
|
||||
nodes.forEach(function(n) { nodeMap[n.pubkey] = n; });
|
||||
var html = '<table style="width:100%;border-collapse:collapse"><thead><tr><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">Node</th><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">Role</th><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">Neighbors</th></tr></thead><tbody>';
|
||||
nodes.slice().sort(function(a, b) { return (a.name || a.pubkey).localeCompare(b.name || b.pubkey); }).forEach(function(n) {
|
||||
var neighbors = (adj[n.pubkey] || []).map(function(nb) {
|
||||
var peer = nodeMap[nb.pk];
|
||||
var name = peer ? (peer.name || nb.pk.slice(0, 8)) : nb.pk.slice(0, 8);
|
||||
var conf = nb.ambiguous ? ' ⚠' : (nb.score >= 0.5 ? ' ●' : ' ○');
|
||||
return esc(name) + conf;
|
||||
}).join(', ');
|
||||
html += '<tr><td style="padding:4px;border-bottom:1px solid var(--border)">' + esc(n.name || n.pubkey.slice(0, 12)) + '</td><td style="padding:4px;border-bottom:1px solid var(--border)">' + esc(n.role || 'unknown') + '</td><td style="padding:4px;border-bottom:1px solid var(--border)">' + (neighbors || '<em>none</em>') + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
html += '<p style="margin-top:8px;font-size:11px;color:var(--text-secondary)">● = high confidence (score ≥ 0.5), ○ = low confidence, ⚠ = ambiguous/unresolved</p>';
|
||||
listEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function startGraphRenderer() {
|
||||
if (!_ngState) return;
|
||||
|
||||
// Node count guard: skip force simulation for very large graphs
|
||||
var NODE_LIMIT = 1000;
|
||||
if (_ngState.allNodes.length > NODE_LIMIT) {
|
||||
var el = document.getElementById('ngCanvas');
|
||||
if (el) {
|
||||
el.style.display = 'none';
|
||||
var msg = document.createElement('div');
|
||||
msg.className = 'analytics-card';
|
||||
msg.innerHTML = '<p class="text-muted">Graph has ' + _ngState.allNodes.length + ' nodes (limit: ' + NODE_LIMIT + '). Force simulation skipped for performance. Use filters to reduce the node count.</p>';
|
||||
el.parentNode.insertBefore(msg, el);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('ngCanvas');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = canvas.clientHeight * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const W = canvas.clientWidth, H = canvas.clientHeight;
|
||||
|
||||
// Interaction
|
||||
let hoverNode = null;
|
||||
|
||||
function canvasToGraph(cx, cy) {
|
||||
return { x: (cx - _ngState.panX) / _ngState.zoom, y: (cy - _ngState.panY) / _ngState.zoom };
|
||||
}
|
||||
|
||||
function findNode(cx, cy) {
|
||||
const gp = canvasToGraph(cx, cy);
|
||||
for (let i = _ngState.nodes.length - 1; i >= 0; i--) {
|
||||
const n = _ngState.nodes[i];
|
||||
const dx = gp.x - n.x, dy = gp.y - n.y;
|
||||
if (dx * dx + dy * dy <= n.radius * n.radius) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', function(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
||||
const n = findNode(cx, cy);
|
||||
if (n) {
|
||||
_ngState.dragging = n;
|
||||
n._pinned = true;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
} else {
|
||||
_ngState.panning = true;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
_ngState.lastMouseX = e.clientX;
|
||||
_ngState.lastMouseY = e.clientY;
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', function(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
||||
if (_ngState.dragging) {
|
||||
const dx = (e.clientX - _ngState.lastMouseX) / _ngState.zoom;
|
||||
const dy = (e.clientY - _ngState.lastMouseY) / _ngState.zoom;
|
||||
_ngState.dragging.x += dx;
|
||||
_ngState.dragging.y += dy;
|
||||
_ngState.lastMouseX = e.clientX;
|
||||
_ngState.lastMouseY = e.clientY;
|
||||
_ngState.cooling = Math.max(_ngState.cooling, 0.3);
|
||||
} else if (_ngState.panning) {
|
||||
_ngState.panX += e.clientX - _ngState.lastMouseX;
|
||||
_ngState.panY += e.clientY - _ngState.lastMouseY;
|
||||
_ngState.lastMouseX = e.clientX;
|
||||
_ngState.lastMouseY = e.clientY;
|
||||
} else {
|
||||
const n = findNode(cx, cy);
|
||||
if (n !== hoverNode) {
|
||||
hoverNode = n;
|
||||
canvas.style.cursor = n ? 'pointer' : 'grab';
|
||||
const tip = document.getElementById('ngTooltip');
|
||||
if (n && tip) {
|
||||
tip.style.display = 'block';
|
||||
tip.style.left = (cx + 12) + 'px';
|
||||
tip.style.top = (cy - 8) + 'px';
|
||||
tip.innerHTML = `<strong>${esc(n.name || n.pubkey.slice(0, 12) + '…')}</strong><br>Role: ${esc(n.role || 'unknown')}<br>Neighbors: ${n.neighbor_count || 0}`;
|
||||
} else if (tip) {
|
||||
tip.style.display = 'none';
|
||||
}
|
||||
} else if (hoverNode) {
|
||||
const tip = document.getElementById('ngTooltip');
|
||||
if (tip) { tip.style.left = (cx + 12) + 'px'; tip.style.top = (cy - 8) + 'px'; }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', function() {
|
||||
if (_ngState.dragging) {
|
||||
_ngState.dragging._pinned = false;
|
||||
_ngState._wasDragging = true;
|
||||
}
|
||||
_ngState.dragging = null;
|
||||
_ngState.panning = false;
|
||||
canvas.style.cursor = hoverNode ? 'pointer' : 'grab';
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseleave', function() {
|
||||
_ngState.dragging = null;
|
||||
_ngState.panning = false;
|
||||
_ngState._wasDragging = false;
|
||||
const tip = document.getElementById('ngTooltip');
|
||||
if (tip) tip.style.display = 'none';
|
||||
hoverNode = null;
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', function(e) {
|
||||
if (_ngState._wasDragging) { _ngState._wasDragging = false; return; }
|
||||
if (_ngState.dragging) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const n = findNode(e.clientX - rect.left, e.clientY - rect.top);
|
||||
if (n) location.hash = '#/nodes/' + n.pubkey;
|
||||
});
|
||||
|
||||
canvas.addEventListener('keydown', function(e) {
|
||||
const PAN_STEP = 30, ZOOM_STEP = 1.15;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft': _ngState.panX += PAN_STEP; e.preventDefault(); break;
|
||||
case 'ArrowRight': _ngState.panX -= PAN_STEP; e.preventDefault(); break;
|
||||
case 'ArrowUp': _ngState.panY += PAN_STEP; e.preventDefault(); break;
|
||||
case 'ArrowDown': _ngState.panY -= PAN_STEP; e.preventDefault(); break;
|
||||
case '+': case '=': _ngState.zoom = Math.min(10, _ngState.zoom * ZOOM_STEP); e.preventDefault(); break;
|
||||
case '-': case '_': _ngState.zoom = Math.max(0.1, _ngState.zoom / ZOOM_STEP); e.preventDefault(); break;
|
||||
case '0': _ngState.zoom = 1; _ngState.panX = 0; _ngState.panY = 0; e.preventDefault(); break;
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
||||
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
const newZoom = Math.max(0.1, Math.min(10, _ngState.zoom * factor));
|
||||
// Zoom towards mouse position
|
||||
_ngState.panX = cx - (cx - _ngState.panX) * (newZoom / _ngState.zoom);
|
||||
_ngState.panY = cy - (cy - _ngState.panY) * (newZoom / _ngState.zoom);
|
||||
_ngState.zoom = newZoom;
|
||||
}, { passive: false });
|
||||
|
||||
// Cache text color to avoid getComputedStyle every frame
|
||||
const _labelColor = cssVar('--text-primary') || '#e0e0e0';
|
||||
|
||||
// Force simulation + render loop
|
||||
// Performance: 500 nodes brute-force repulsion: avg ~4ms/frame = 250fps headroom (measured Chrome 120, M1)
|
||||
var _perfFrameTimes = [], _perfLastTime = 0;
|
||||
function tick() {
|
||||
if (!document.getElementById('ngCanvas')) { _ngState.animId = null; return; }
|
||||
var now = performance.now();
|
||||
if (_perfLastTime) _perfFrameTimes.push(now - _perfLastTime);
|
||||
_perfLastTime = now;
|
||||
if (_perfFrameTimes.length === 100) {
|
||||
var avg = _perfFrameTimes.reduce(function(a, b) { return a + b; }, 0) / 100;
|
||||
console.log('[NeighborGraph perf] avg frame time over 100 frames: ' + avg.toFixed(2) + 'ms (' + (1000 / avg).toFixed(0) + ' fps)');
|
||||
_perfFrameTimes = [];
|
||||
}
|
||||
const st = _ngState;
|
||||
const nodes = st.nodes, edges = st.edges, idx = st.nodeIdx;
|
||||
|
||||
if (st.cooling > 0.001) {
|
||||
// Repulsion (all pairs — use grid for large sets, brute force for small)
|
||||
const k = 80; // repulsion constant
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
let dx = nodes[j].x - nodes[i].x;
|
||||
let dy = nodes[j].y - nodes[i].y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 < 1) { dx = Math.random() - 0.5; dy = Math.random() - 0.5; d2 = 1; }
|
||||
const f = k * k / d2;
|
||||
const fx = dx / Math.sqrt(d2) * f;
|
||||
const fy = dy / Math.sqrt(d2) * f;
|
||||
nodes[i].vx -= fx; nodes[i].vy -= fy;
|
||||
nodes[j].vx += fx; nodes[j].vy += fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
const idealLen = 120;
|
||||
for (const e of edges) {
|
||||
const si = idx[e.source], ti = idx[e.target];
|
||||
if (si === undefined || ti === undefined) continue;
|
||||
const a = nodes[si], b = nodes[ti];
|
||||
let dx = b.x - a.x, dy = b.y - a.y;
|
||||
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const f = (d - idealLen) * 0.05 * (0.5 + e.score * 0.5);
|
||||
const fx = dx / d * f, fy = dy / d * f;
|
||||
a.vx += fx; a.vy += fy;
|
||||
b.vx -= fx; b.vy -= fy;
|
||||
}
|
||||
|
||||
// Center gravity
|
||||
for (const n of nodes) {
|
||||
n.vx += (W / 2 - n.x) * 0.001;
|
||||
n.vy += (H / 2 - n.y) * 0.001;
|
||||
}
|
||||
|
||||
// Apply velocities with damping
|
||||
const damping = 0.85;
|
||||
for (const n of nodes) {
|
||||
if (n._pinned) { n.vx = 0; n.vy = 0; continue; }
|
||||
n.vx *= damping * st.cooling;
|
||||
n.vy *= damping * st.cooling;
|
||||
const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
|
||||
if (speed > 10) { n.vx *= 10 / speed; n.vy *= 10 / speed; }
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
}
|
||||
st.cooling *= 0.995;
|
||||
}
|
||||
|
||||
// Render
|
||||
ctx.save();
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.translate(st.panX, st.panY);
|
||||
ctx.scale(st.zoom, st.zoom);
|
||||
|
||||
// Edges
|
||||
for (const e of edges) {
|
||||
const si = idx[e.source], ti = idx[e.target];
|
||||
if (si === undefined || ti === undefined) continue;
|
||||
const a = nodes[si], b = nodes[ti];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = e.ambiguous ? 'rgba(255,200,0,0.4)' : 'rgba(150,150,150,0.35)';
|
||||
ctx.lineWidth = Math.max(0.5, e.score * 4);
|
||||
if (e.ambiguous) { ctx.setLineDash([4, 4]); } else { ctx.setLineDash([]); }
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Nodes
|
||||
const roleColors = window.ROLE_COLORS || {};
|
||||
for (const n of nodes) {
|
||||
const color = roleColors[(n.role || '').toLowerCase()] || '#6b7280';
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
if (n === hoverNode) {
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
// Label
|
||||
const label = n.name || (n.pubkey ? n.pubkey.slice(0, 8) + '…' : '');
|
||||
if (label && st.zoom > 0.4) {
|
||||
ctx.fillStyle = _labelColor;
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(label, n.x, n.y + n.radius + 12);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
st.animId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
_ngState.animId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
})();
|
||||
|
||||
+85
-19
@@ -136,6 +136,13 @@ function getTimestampCustomFormat() {
|
||||
function pad2(v) { return String(v).padStart(2, '0'); }
|
||||
function pad3(v) { return String(v).padStart(3, '0'); }
|
||||
|
||||
function mergeUserHomeConfig(siteConfig, userTheme) {
|
||||
if (!siteConfig || !userTheme || !userTheme.home || typeof userTheme.home !== 'object') return siteConfig;
|
||||
const serverHome = (siteConfig.home && typeof siteConfig.home === 'object') ? siteConfig.home : {};
|
||||
siteConfig.home = Object.assign({}, serverHome, userTheme.home);
|
||||
return siteConfig;
|
||||
}
|
||||
|
||||
function formatIsoLike(d, timezone, includeMs) {
|
||||
const useUtc = timezone === 'utc';
|
||||
const year = useUtc ? d.getUTCFullYear() : d.getFullYear();
|
||||
@@ -463,9 +470,6 @@ function navigate() {
|
||||
currentPage = basePage;
|
||||
|
||||
const app = document.getElementById('app');
|
||||
// Pages with fixed-height containers (maps, virtual-scroll, split-panels)
|
||||
const fixedPages = { packets: 1, nodes: 1, map: 1, live: 1, channels: 1, 'audio-lab': 1 };
|
||||
app.classList.toggle('app-fixed', basePage in fixedPages);
|
||||
if (pages[basePage]?.init) {
|
||||
const t0 = performance.now();
|
||||
pages[basePage].init(app, routeParam);
|
||||
@@ -790,30 +794,92 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
debouncedOnWS(function () { updateNavStats(); });
|
||||
|
||||
// --- Theme Customization ---
|
||||
// Fetch theme config and apply via customizer v2 pipeline
|
||||
// Fetch theme config and apply branding/colors before first render
|
||||
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
|
||||
// Normalize timestamp defaults
|
||||
cfg = cfg || {};
|
||||
if (!cfg.timestamps) cfg.timestamps = {};
|
||||
const tsCfg = cfg.timestamps;
|
||||
window.SITE_CONFIG = cfg || {};
|
||||
if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
|
||||
const tsCfg = window.SITE_CONFIG.timestamps;
|
||||
if (tsCfg.defaultMode !== 'absolute' && tsCfg.defaultMode !== 'ago') tsCfg.defaultMode = 'ago';
|
||||
if (tsCfg.timezone !== 'utc' && tsCfg.timezone !== 'local') tsCfg.timezone = 'local';
|
||||
if (tsCfg.formatPreset !== 'iso' && tsCfg.formatPreset !== 'iso-seconds' && tsCfg.formatPreset !== 'locale') tsCfg.formatPreset = 'iso';
|
||||
if (typeof tsCfg.customFormat !== 'string') tsCfg.customFormat = '';
|
||||
tsCfg.allowCustomFormat = tsCfg.allowCustomFormat === true;
|
||||
|
||||
// Customizer v2: set server defaults and run full pipeline
|
||||
// (reads localStorage overrides → merges → sets SITE_CONFIG → applies CSS → dispatches theme-changed)
|
||||
if (window._customizerV2) {
|
||||
window._customizerV2.init(cfg);
|
||||
} else {
|
||||
// Fallback if customize-v2.js didn't load
|
||||
window.SITE_CONFIG = cfg;
|
||||
// User's localStorage preferences take priority over server config
|
||||
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
|
||||
window._SITE_CONFIG_ORIGINAL_HOME = JSON.parse(JSON.stringify(window.SITE_CONFIG.home || {}));
|
||||
mergeUserHomeConfig(window.SITE_CONFIG, userTheme);
|
||||
|
||||
// Apply CSS variable overrides from theme config (skipped if user has local overrides)
|
||||
if (!userTheme.theme && !userTheme.themeDark) {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const themeData = dark ? { ...(cfg.theme || {}), ...(cfg.themeDark || {}) } : (cfg.theme || {});
|
||||
const root = document.documentElement.style;
|
||||
const varMap = {
|
||||
accent: '--accent', accentHover: '--accent-hover',
|
||||
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
|
||||
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
|
||||
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
|
||||
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
|
||||
cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg',
|
||||
rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg',
|
||||
selectedBg: '--selected-bg', sectionBg: '--section-bg',
|
||||
font: '--font', mono: '--mono'
|
||||
};
|
||||
for (const [key, cssVar] of Object.entries(varMap)) {
|
||||
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
|
||||
}
|
||||
// Derived vars
|
||||
if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background);
|
||||
if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1);
|
||||
// Nav gradient
|
||||
if (themeData.navBg) {
|
||||
const nav = document.querySelector('.top-nav');
|
||||
if (nav) nav.style.background = `linear-gradient(135deg, ${themeData.navBg} 0%, ${themeData.navBg2 || themeData.navBg} 50%, ${themeData.navBg} 100%)`;
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } };
|
||||
if (window._customizerV2) window._customizerV2.init(window.SITE_CONFIG);
|
||||
}).finally(() => {
|
||||
|
||||
// Apply node color overrides (skip if user has local preferences)
|
||||
if (cfg.nodeColors && !userTheme.nodeColors) {
|
||||
for (const [role, color] of Object.entries(cfg.nodeColors)) {
|
||||
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = color;
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply type color overrides (skip if user has local preferences)
|
||||
if (cfg.typeColors && !userTheme.typeColors) {
|
||||
for (const [type, color] of Object.entries(cfg.typeColors)) {
|
||||
if (window.TYPE_COLORS && type in window.TYPE_COLORS) window.TYPE_COLORS[type] = color;
|
||||
}
|
||||
if (window.syncBadgeColors) window.syncBadgeColors();
|
||||
}
|
||||
|
||||
// Apply branding (skip if user has local preferences)
|
||||
if (cfg.branding && !userTheme.branding) {
|
||||
if (cfg.branding.siteName) {
|
||||
document.title = cfg.branding.siteName;
|
||||
const brandText = document.querySelector('.brand-text');
|
||||
if (brandText) brandText.textContent = cfg.branding.siteName;
|
||||
}
|
||||
if (cfg.branding.logoUrl) {
|
||||
const brandIcon = document.querySelector('.brand-icon');
|
||||
if (brandIcon) {
|
||||
const img = document.createElement('img');
|
||||
img.src = cfg.branding.logoUrl;
|
||||
img.alt = cfg.branding.siteName || 'Logo';
|
||||
img.style.height = '24px';
|
||||
img.style.width = 'auto';
|
||||
brandIcon.replaceWith(img);
|
||||
}
|
||||
}
|
||||
if (cfg.branding.faviconUrl) {
|
||||
const favicon = document.querySelector('link[rel="icon"]');
|
||||
if (favicon) favicon.href = cfg.branding.faviconUrl;
|
||||
}
|
||||
}
|
||||
}).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } }; }).finally(() => {
|
||||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||||
else navigate();
|
||||
});
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
|
||||
packetsB = [];
|
||||
currentView = 'summary';
|
||||
|
||||
app.innerHTML = '<div class="compare-page" style="padding:16px">' +
|
||||
app.innerHTML = '<div class="compare-page" style="overflow-y:auto;height:calc(100vh - 56px);padding:16px">' +
|
||||
'<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">' +
|
||||
'<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">\u2190</a>' +
|
||||
'<h2 style="margin:0">\uD83D\uDD0D Observer Comparison</h2>' +
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -100,6 +100,11 @@
|
||||
selectedBg: '--selected-bg',
|
||||
font: '--font',
|
||||
mono: '--mono',
|
||||
// Hex breakdown section colors
|
||||
sectionHeaderBg: '--section-header-bg',
|
||||
sectionTransportBg: '--section-transport-bg',
|
||||
sectionPathBg: '--section-path-bg',
|
||||
sectionPayloadBg: '--section-payload-bg',
|
||||
};
|
||||
|
||||
/* ── Theme Presets ── */
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
/* === CoreScope — home.css === */
|
||||
|
||||
/* Home page now uses body scroll (no #app override needed — see style.css) */
|
||||
/* Override #app overflow:hidden for home page scrolling */
|
||||
#app:has(.home-hero), #app:has(.home-chooser) { overflow-y: auto; }
|
||||
|
||||
/* Chooser */
|
||||
.home-chooser {
|
||||
|
||||
+19
-27
@@ -511,35 +511,27 @@
|
||||
function timeSinceMs(d) { return Date.now() - d.getTime(); }
|
||||
|
||||
function checklist(homeCfg) {
|
||||
var html = '';
|
||||
// Render steps (getting started guide)
|
||||
if (homeCfg?.steps?.length) {
|
||||
html += homeCfg.steps.map(s => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(s.emoji || '')} ${escapeHtml(s.title)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(s.description) : escapeHtml(s.description)}</div></div>`).join('');
|
||||
if (homeCfg?.checklist) {
|
||||
return homeCfg.checklist.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(i.question)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(i.answer) : escapeHtml(i.answer)}</div></div>`).join('');
|
||||
}
|
||||
// Render FAQ/checklist (additional Q&A)
|
||||
if (homeCfg?.checklist?.length) {
|
||||
if (html) html += '<h3 style="margin:24px 0 12px;font-size:16px">❓ FAQ</h3>';
|
||||
html += homeCfg.checklist.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(i.question)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(i.answer) : escapeHtml(i.answer)}</div></div>`).join('');
|
||||
if (homeCfg?.steps) {
|
||||
return homeCfg.steps.map(s => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(s.emoji || '')} ${escapeHtml(s.title)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(s.description) : escapeHtml(s.description)}</div></div>`).join('');
|
||||
}
|
||||
// Fallback: Bay Area defaults when no config at all
|
||||
if (!html) {
|
||||
const items = [
|
||||
{ q: '💬 First: Join the Bay Area MeshCore Discord',
|
||||
a: '<p>The community Discord is the best place to get help and find local mesh enthusiasts.</p><p><a href="https://discord.gg/q59JzsYTst" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">Join the Discord ↗</a></p><p>Start with <strong>#intro-to-meshcore</strong> — it has detailed setup instructions.</p>' },
|
||||
{ q: '🔵 Step 1: Connect via Bluetooth',
|
||||
a: '<p>Flash <strong>BLE companion</strong> firmware from <a href="https://flasher.meshcore.co.uk/" target="_blank" rel="noopener" style="color:var(--accent)">MeshCore Flasher</a>.</p><ul><li>Screenless devices: default PIN <code>123456</code></li><li>Screen devices: random PIN shown on display</li><li>If pairing fails: forget device, reboot, re-pair</li></ul>' },
|
||||
{ q: '📻 Step 2: Set the right frequency preset',
|
||||
a: '<p><strong>US Recommended:</strong></p><div style="margin:8px 0;padding:8px 12px;background:var(--surface-1);border-radius:6px;font-family:var(--mono);font-size:.85rem">910.525 MHz · BW 62.5 kHz · SF 7 · CR 5</div><p>Select <strong>"US Recommended"</strong> in the app or flasher.</p>' },
|
||||
{ q: '📡 Step 3: Advertise yourself',
|
||||
a: '<p>Tap the signal icon → <strong>Flood</strong> to broadcast your node to the mesh. Companions only advert when you trigger it manually.</p>' },
|
||||
{ q: '🔁 Step 4: Check "Heard N repeats"',
|
||||
a: '<ul><li><strong>"Sent"</strong> = transmitted, no confirmation</li><li><strong>"Heard 0 repeats"</strong> = no repeater picked it up</li><li><strong>"Heard 1+ repeats"</strong> = you\'re on the mesh!</li></ul>' },
|
||||
{ q: '📍 Repeaters near you?',
|
||||
a: '<p><a href="#/map" style="color:var(--accent)">Check the network map</a> to see active repeaters.</p>' }
|
||||
];
|
||||
html = items.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${i.q}</div><div class="checklist-a">${i.a}</div></div>`).join('');
|
||||
}
|
||||
return html;
|
||||
const items = [
|
||||
{ q: '💬 First: Join the Bay Area MeshCore Discord',
|
||||
a: '<p>The community Discord is the best place to get help and find local mesh enthusiasts.</p><p><a href="https://discord.gg/q59JzsYTst" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">Join the Discord ↗</a></p><p>Start with <strong>#intro-to-meshcore</strong> — it has detailed setup instructions.</p>' },
|
||||
{ q: '🔵 Step 1: Connect via Bluetooth',
|
||||
a: '<p>Flash <strong>BLE companion</strong> firmware from <a href="https://flasher.meshcore.co.uk/" target="_blank" rel="noopener" style="color:var(--accent)">MeshCore Flasher</a>.</p><ul><li>Screenless devices: default PIN <code>123456</code></li><li>Screen devices: random PIN shown on display</li><li>If pairing fails: forget device, reboot, re-pair</li></ul>' },
|
||||
{ q: '📻 Step 2: Set the right frequency preset',
|
||||
a: '<p><strong>US Recommended:</strong></p><div style="margin:8px 0;padding:8px 12px;background:var(--surface-1);border-radius:6px;font-family:var(--mono);font-size:.85rem">910.525 MHz · BW 62.5 kHz · SF 7 · CR 5</div><p>Select <strong>"US Recommended"</strong> in the app or flasher.</p>' },
|
||||
{ q: '📡 Step 3: Advertise yourself',
|
||||
a: '<p>Tap the signal icon → <strong>Flood</strong> to broadcast your node to the mesh. Companions only advert when you trigger it manually.</p>' },
|
||||
{ q: '🔁 Step 4: Check "Heard N repeats"',
|
||||
a: '<ul><li><strong>"Sent"</strong> = transmitted, no confirmation</li><li><strong>"Heard 0 repeats"</strong> = no repeater picked it up</li><li><strong>"Heard 1+ repeats"</strong> = you\'re on the mesh!</li></ul>' },
|
||||
{ q: '📍 Repeaters near you?',
|
||||
a: '<p><a href="#/map" style="color:var(--accent)">Check the network map</a> to see active repeaters.</p>' }
|
||||
];
|
||||
return items.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${i.q}</div><div class="checklist-a">${i.a}</div></div>`).join('');
|
||||
}
|
||||
|
||||
registerPage('home', { init, destroy });
|
||||
|
||||
+17
-107
@@ -8,11 +8,9 @@ window.HopResolver = (function() {
|
||||
const MAX_HOP_DIST = 1.8; // ~200km in degrees
|
||||
const REGION_RADIUS_KM = 300;
|
||||
let prefixIdx = {}; // lowercase hex prefix → [node, ...]
|
||||
let pubkeyIdx = {}; // full lowercase pubkey → node (O(1) lookup)
|
||||
let nodesList = [];
|
||||
let observerIataMap = {}; // observer_id → iata
|
||||
let iataCoords = {}; // iata → {lat, lon}
|
||||
let affinityMap = {}; // pubkey → { neighborPubkey → score }
|
||||
|
||||
function dist(lat1, lon1, lat2, lon2) {
|
||||
return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
|
||||
@@ -36,11 +34,9 @@ window.HopResolver = (function() {
|
||||
function init(nodes, opts) {
|
||||
nodesList = nodes || [];
|
||||
prefixIdx = {};
|
||||
pubkeyIdx = {};
|
||||
for (const n of nodesList) {
|
||||
if (!n.public_key) continue;
|
||||
const pk = n.public_key.toLowerCase();
|
||||
pubkeyIdx[pk] = n;
|
||||
for (let len = 1; len <= 3; len++) {
|
||||
const p = pk.slice(0, len * 2);
|
||||
if (!prefixIdx[p]) prefixIdx[p] = [];
|
||||
@@ -71,34 +67,6 @@ window.HopResolver = (function() {
|
||||
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.
|
||||
* Returns a map: { hop: {name, pubkey, lat, lon, ambiguous, unreliable} }
|
||||
@@ -171,50 +139,40 @@ window.HopResolver = (function() {
|
||||
|
||||
// Forward pass
|
||||
let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null;
|
||||
let lastResolvedPubkey = null;
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
const hop = hops[i];
|
||||
if (hopPositions[hop]) {
|
||||
lastPos = hopPositions[hop];
|
||||
lastResolvedPubkey = resolved[hop] ? resolved[hop].pubkey : null;
|
||||
continue;
|
||||
}
|
||||
if (hopPositions[hop]) { lastPos = hopPositions[hop]; continue; }
|
||||
const r = resolved[hop];
|
||||
if (!r || !r.ambiguous) continue;
|
||||
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
|
||||
if (!withLoc.length) continue;
|
||||
|
||||
// Affinity-aware: prefer candidates that are neighbors of the previous hop
|
||||
const picked = pickByAffinity(withLoc, lastResolvedPubkey, lastPos, i === hops.length - 1 ? observerLat : null, i === hops.length - 1 ? observerLon : null);
|
||||
r.name = picked.name;
|
||||
r.pubkey = picked.pubkey;
|
||||
hopPositions[hop] = { lat: picked.lat, lon: picked.lon };
|
||||
let anchor = lastPos;
|
||||
if (!anchor && i === hops.length - 1 && observerLat != null) {
|
||||
anchor = { lat: observerLat, lon: observerLon };
|
||||
}
|
||||
if (anchor) {
|
||||
withLoc.sort((a, b) => dist(a.lat, a.lon, anchor.lat, anchor.lon) - dist(b.lat, b.lon, anchor.lat, anchor.lon));
|
||||
}
|
||||
r.name = withLoc[0].name;
|
||||
r.pubkey = withLoc[0].pubkey;
|
||||
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
|
||||
lastPos = hopPositions[hop];
|
||||
lastResolvedPubkey = picked.pubkey;
|
||||
}
|
||||
|
||||
// Backward pass
|
||||
let nextPos = (observerLat != null && observerLon != null) ? { lat: observerLat, lon: observerLon } : null;
|
||||
let nextResolvedPubkey = null;
|
||||
for (let i = hops.length - 1; i >= 0; i--) {
|
||||
const hop = hops[i];
|
||||
if (hopPositions[hop]) {
|
||||
nextPos = hopPositions[hop];
|
||||
nextResolvedPubkey = resolved[hop] ? resolved[hop].pubkey : null;
|
||||
continue;
|
||||
}
|
||||
if (hopPositions[hop]) { nextPos = hopPositions[hop]; continue; }
|
||||
const r = resolved[hop];
|
||||
if (!r || !r.ambiguous) continue;
|
||||
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
|
||||
if (!withLoc.length || !nextPos) continue;
|
||||
|
||||
// Affinity-aware: prefer candidates that are neighbors of the next hop
|
||||
const picked = pickByAffinity(withLoc, nextResolvedPubkey, nextPos, null, null);
|
||||
r.name = picked.name;
|
||||
r.pubkey = picked.pubkey;
|
||||
hopPositions[hop] = { lat: picked.lat, lon: picked.lon };
|
||||
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;
|
||||
r.pubkey = withLoc[0].pubkey;
|
||||
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
|
||||
nextPos = hopPositions[hop];
|
||||
nextResolvedPubkey = picked.pubkey;
|
||||
}
|
||||
|
||||
// Sanity check: drop hops impossibly far from neighbors
|
||||
@@ -245,53 +203,5 @@ window.HopResolver = (function() {
|
||||
return nodesList.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve hops using server-provided resolved_path (full pubkeys).
|
||||
* Returns the same format as resolve() — { [hop]: { name, pubkey, ... } }.
|
||||
* resolved_path is an array aligned with path_json: each element is a
|
||||
* 64-char lowercase hex pubkey or null. Skips entries that are null.
|
||||
*/
|
||||
function resolveFromServer(hops, resolvedPath) {
|
||||
if (!hops || !resolvedPath || hops.length !== resolvedPath.length) return {};
|
||||
var result = {};
|
||||
for (var i = 0; i < hops.length; i++) {
|
||||
var hop = hops[i];
|
||||
var pubkey = resolvedPath[i];
|
||||
if (!pubkey) continue; // null = unresolved, leave for client-side fallback
|
||||
// O(1) lookup via pubkeyIdx built during init()
|
||||
var node = pubkeyIdx[pubkey.toLowerCase()] || null;
|
||||
result[hop] = {
|
||||
name: node ? node.name : pubkey.slice(0, 8),
|
||||
pubkey: pubkey,
|
||||
candidates: node ? [{ name: node.name, pubkey: pubkey, lat: node.lat, lon: node.lon }] : [],
|
||||
conflicts: []
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return { init: init, resolve: resolve, resolveFromServer: resolveFromServer, ready: ready, haversineKm: haversineKm, setAffinity: setAffinity, getAffinity: getAffinity };
|
||||
return { init: init, resolve: resolve, ready: ready, haversineKm: haversineKm };
|
||||
})();
|
||||
|
||||
+1
-2
@@ -86,14 +86,13 @@
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=__BUST__"></script>
|
||||
<script src="customize-v2.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="customize.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=__BUST__"></script>
|
||||
<script src="hop-resolver.js?v=__BUST__"></script>
|
||||
<script src="hop-display.js?v=__BUST__"></script>
|
||||
<script src="app.js?v=__BUST__"></script>
|
||||
<script src="home.js?v=__BUST__"></script>
|
||||
<script src="packet-filter.js?v=__BUST__"></script>
|
||||
<script src="packet-helpers.js?v=__BUST__"></script>
|
||||
<script src="packets.js?v=__BUST__"></script>
|
||||
<script src="geo-filter-overlay.js?v=__BUST__"></script>
|
||||
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
+29
-167
@@ -1,10 +1,6 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// getParsedPath / getParsedDecoded are in shared packet-helpers.js (loaded before this file)
|
||||
var getParsedPath = window.getParsedPath;
|
||||
var getParsedDecoded = window.getParsedDecoded;
|
||||
|
||||
// Status color helpers (read from CSS variables for theme support)
|
||||
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
|
||||
@@ -43,7 +39,6 @@
|
||||
timelineScope: 3600000, // 1h default ms
|
||||
timelineTimestamps: [], // historical timestamps from DB for sparkline
|
||||
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')
|
||||
@@ -117,7 +112,6 @@
|
||||
|
||||
function vcrResumeLive() {
|
||||
stopReplay();
|
||||
VCR.replayGen++; // invalidate any in-flight async chunk processing
|
||||
VCR.playhead = -1;
|
||||
VCR.speed = 1;
|
||||
VCR.missedCount = 0;
|
||||
@@ -144,8 +138,6 @@
|
||||
function vcrReplayFromTs(targetTs) {
|
||||
const fetchFrom = new Date(targetTs).toISOString();
|
||||
stopReplay();
|
||||
VCR.replayGen++;
|
||||
var gen = VCR.replayGen;
|
||||
vcrSetMode('REPLAY');
|
||||
|
||||
// Reload map nodes to match the replay time
|
||||
@@ -157,10 +149,7 @@
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const pkts = data.packets || [];
|
||||
return expandToBufferEntriesAsync(pkts);
|
||||
})
|
||||
.then(function(replayEntries) {
|
||||
if (gen !== VCR.replayGen) return; // stale async result — user changed mode
|
||||
const replayEntries = expandToBufferEntries(pkts);
|
||||
if (replayEntries.length === 0) {
|
||||
vcrSetMode('PAUSED');
|
||||
return;
|
||||
@@ -209,8 +198,6 @@
|
||||
|
||||
function vcrRewind(ms) {
|
||||
stopReplay();
|
||||
VCR.replayGen++;
|
||||
var gen = VCR.replayGen;
|
||||
// Fetch packets from DB for the time window
|
||||
const now = Date.now();
|
||||
const from = new Date(now - ms).toISOString();
|
||||
@@ -221,11 +208,8 @@
|
||||
// Prepend to buffer (avoid duplicates by ID)
|
||||
const existingIds = new Set(VCR.buffer.map(b => b.pkt.id).filter(Boolean));
|
||||
const filtered = pkts.filter(p => !existingIds.has(p.id));
|
||||
return expandToBufferEntriesAsync(filtered);
|
||||
})
|
||||
.then(function(newEntries) {
|
||||
if (gen !== VCR.replayGen) return; // stale async result
|
||||
VCR.buffer = [].concat(newEntries, VCR.buffer);
|
||||
const newEntries = expandToBufferEntries(filtered);
|
||||
VCR.buffer = [...newEntries, ...VCR.buffer];
|
||||
VCR.playhead = 0;
|
||||
VCR.speed = 1;
|
||||
vcrSetMode('REPLAY');
|
||||
@@ -286,18 +270,15 @@
|
||||
// Get timestamp of last packet in buffer to fetch the next page
|
||||
const last = VCR.buffer[VCR.buffer.length - 1];
|
||||
if (!last) return Promise.resolve(false);
|
||||
var gen = VCR.replayGen;
|
||||
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`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const pkts = data.packets || [];
|
||||
if (pkts.length === 0) return false;
|
||||
return expandToBufferEntriesAsync(pkts).then(function(newEntries) {
|
||||
if (gen !== VCR.replayGen) return false; // stale
|
||||
VCR.buffer = VCR.buffer.concat(newEntries);
|
||||
return true;
|
||||
});
|
||||
const newEntries = expandToBufferEntries(pkts);
|
||||
VCR.buffer = VCR.buffer.concat(newEntries);
|
||||
return true;
|
||||
})
|
||||
.catch(() => false);
|
||||
}
|
||||
@@ -450,14 +431,13 @@
|
||||
}
|
||||
|
||||
function dbPacketToLive(pkt) {
|
||||
const raw = getParsedDecoded(pkt);
|
||||
const hops = getParsedPath(pkt);
|
||||
const raw = JSON.parse(pkt.decoded_json || '{}');
|
||||
const hops = JSON.parse(pkt.path_json || '[]');
|
||||
const typeName = raw.type || pkt.payload_type_name || 'UNKNOWN';
|
||||
return {
|
||||
id: pkt.id, hash: pkt.hash,
|
||||
raw: pkt.raw_hex,
|
||||
path_json: pkt.path_json,
|
||||
resolved_path: pkt.resolved_path,
|
||||
_ts: new Date(pkt.timestamp || pkt.created_at).getTime(),
|
||||
decoded: { header: { payloadTypeName: typeName }, payload: raw, path: { hops } },
|
||||
snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name
|
||||
@@ -465,53 +445,11 @@
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var entries = [];
|
||||
for (var k = 0; k < pkts.length; k++) {
|
||||
var p = pkts[k];
|
||||
const entries = [];
|
||||
for (const p of pkts) {
|
||||
if (p.observations && p.observations.length > 0) {
|
||||
for (var j = 0; j < p.observations.length; j++) {
|
||||
var obs = p.observations[j];
|
||||
for (const obs of p.observations) {
|
||||
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 }))
|
||||
@@ -543,13 +481,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
function packetTimestamp(pkt) {
|
||||
return new Date(pkt.timestamp || pkt.created_at || Date.now()).getTime();
|
||||
}
|
||||
if (typeof window !== 'undefined') window._live_packetTimestamp = packetTimestamp;
|
||||
|
||||
function bufferPacket(pkt) {
|
||||
pkt._ts = packetTimestamp(pkt);
|
||||
pkt._ts = Date.now();
|
||||
const entry = { ts: pkt._ts, pkt };
|
||||
VCR.buffer.push(entry);
|
||||
// Keep buffer capped at ~2000 — adjust playhead to avoid stale indices (#63)
|
||||
@@ -1344,7 +1277,7 @@
|
||||
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Recent Packets</h4>
|
||||
<div style="font-size:11px;max-height:200px;overflow-y:auto;">` +
|
||||
recent.slice(0, 10).map(p => `<div style="padding:2px 0;display:flex;justify-content:space-between;">
|
||||
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${transportBadge(p.route_type)}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
|
||||
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
|
||||
<span style="color:var(--text-muted)">${formatLiveTimestampHtml(p.timestamp)}</span>
|
||||
</div>`).join('') +
|
||||
'</div>';
|
||||
@@ -1417,29 +1350,9 @@
|
||||
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
|
||||
// Initialize shared HopResolver with loaded nodes
|
||||
if (window.HopResolver) HopResolver.init(list);
|
||||
// Fetch affinity data for hop disambiguation
|
||||
fetchAffinityData();
|
||||
startAffinityRefresh();
|
||||
} 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() {
|
||||
if (nodesLayer) nodesLayer.clearLayers();
|
||||
if (animLayer) animLayer.clearLayers();
|
||||
@@ -1527,7 +1440,7 @@
|
||||
for (const op of group.packets) {
|
||||
let opHops = [];
|
||||
if (op.path_json) {
|
||||
try { opHops = getParsedPath(op); } catch {}
|
||||
try { opHops = typeof op.path_json === 'string' ? JSON.parse(op.path_json) : op.path_json; } catch {}
|
||||
} else if (op.decoded?.path?.hops) {
|
||||
opHops = op.decoded.path.hops;
|
||||
}
|
||||
@@ -1549,7 +1462,7 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(group.latestTs || Date.now())}</span>
|
||||
`;
|
||||
@@ -1651,7 +1564,6 @@
|
||||
}
|
||||
delete nodeMarkers[key];
|
||||
delete nodeData[key];
|
||||
delete nodeActivity[key];
|
||||
pruned = true;
|
||||
}
|
||||
} else if (marker && marker._staleDimmed) {
|
||||
@@ -1667,33 +1579,13 @@
|
||||
if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
|
||||
if (window.HopResolver) HopResolver.init(Object.values(nodeData));
|
||||
}
|
||||
// Prune orphaned nodeActivity entries (nodes removed above or never tracked)
|
||||
for (var aKey in nodeActivity) {
|
||||
if (!(aKey in nodeData)) delete nodeActivity[aKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for testing
|
||||
window._livePruneStaleNodes = pruneStaleNodes;
|
||||
window._liveNodeMarkers = function() { return nodeMarkers; };
|
||||
window._liveNodeData = function() { return nodeData; };
|
||||
window._liveNodeActivity = function() { return nodeActivity; };
|
||||
window._vcrFormatTime = vcrFormatTime;
|
||||
window._liveDbPacketToLive = dbPacketToLive;
|
||||
window._liveExpandToBufferEntries = expandToBufferEntries;
|
||||
window._liveExpandToBufferEntriesAsync = expandToBufferEntriesAsync;
|
||||
window._liveSEG_MAP = SEG_MAP;
|
||||
window._liveBufferPacket = bufferPacket;
|
||||
window._liveVCR = function() { return VCR; };
|
||||
window._liveGetFavoritePubkeys = getFavoritePubkeys;
|
||||
window._livePacketInvolvesFavorite = packetInvolvesFavorite;
|
||||
window._liveIsNodeFavorited = isNodeFavorited;
|
||||
window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml;
|
||||
window._liveResolveHopPositions = resolveHopPositions;
|
||||
window._liveVcrSpeedCycle = vcrSpeedCycle;
|
||||
window._liveVcrPause = vcrPause;
|
||||
window._liveVcrResumeLive = vcrResumeLive;
|
||||
window._liveVcrSetMode = vcrSetMode;
|
||||
|
||||
async function replayRecent() {
|
||||
try {
|
||||
@@ -1818,7 +1710,7 @@
|
||||
for (const fp of packets) {
|
||||
let fpHops = [];
|
||||
if (fp.path_json) {
|
||||
try { fpHops = getParsedPath(fp); } catch {}
|
||||
try { fpHops = typeof fp.path_json === 'string' ? JSON.parse(fp.path_json) : fp.path_json; } catch {}
|
||||
} else if (fp.decoded?.path?.hops) {
|
||||
fpHops = fp.decoded.path.hops;
|
||||
}
|
||||
@@ -1855,14 +1747,14 @@
|
||||
var qp = qd.payload || {};
|
||||
var hops;
|
||||
if (qpkt.path_json) {
|
||||
try { hops = getParsedPath(qpkt); } catch (e) { hops = qd.path?.hops || []; }
|
||||
try { hops = typeof qpkt.path_json === 'string' ? JSON.parse(qpkt.path_json) : qpkt.path_json; } catch (e) { hops = qd.path?.hops || []; }
|
||||
} else {
|
||||
hops = qd.path?.hops || [];
|
||||
}
|
||||
var pathKey = hops.join(',');
|
||||
if (seenPathKeys.has(pathKey)) continue;
|
||||
seenPathKeys.add(pathKey);
|
||||
var hopPositions = resolveHopPositions(hops, qp, window.getResolvedPath ? getResolvedPath(qpkt) : null);
|
||||
var hopPositions = resolveHopPositions(hops, qp);
|
||||
if (hopPositions.length >= 2) {
|
||||
allPaths.push({ hopPositions: hopPositions, raw: qpkt.raw || first.raw });
|
||||
} else if (hopPositions.length === 1) {
|
||||
@@ -1899,29 +1791,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHopPositions(hops, payload, resolvedPath) {
|
||||
// Prefer server-side resolved_path when available
|
||||
var resolvedMap;
|
||||
if (resolvedPath && resolvedPath.length === hops.length && window.HopResolver && HopResolver.ready()) {
|
||||
resolvedMap = HopResolver.resolveFromServer(hops, resolvedPath);
|
||||
// Fill in any null entries from client-side fallback, preserving sender GPS context
|
||||
var nullHops = hops.filter(function(h, i) { return !resolvedPath[i] && !resolvedMap[h]; });
|
||||
if (nullHops.length) {
|
||||
const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null;
|
||||
const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null;
|
||||
var fallback = HopResolver.resolve(nullHops, originLat, originLon, null, null, null);
|
||||
for (var k in fallback) resolvedMap[k] = fallback[k];
|
||||
}
|
||||
} else {
|
||||
// Delegate to shared HopResolver (from hop-resolver.js) instead of reimplementing
|
||||
const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null;
|
||||
const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null;
|
||||
function resolveHopPositions(hops, payload) {
|
||||
// Delegate to shared HopResolver (from hop-resolver.js) instead of reimplementing
|
||||
const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null;
|
||||
const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null;
|
||||
|
||||
// Use HopResolver if available and initialized, otherwise fall back to simple lookup
|
||||
resolvedMap = (window.HopResolver && HopResolver.ready())
|
||||
? HopResolver.resolve(hops, originLat, originLon, null, null, null)
|
||||
: {};
|
||||
}
|
||||
// Use HopResolver if available and initialized, otherwise fall back to simple lookup
|
||||
const resolvedMap = (window.HopResolver && HopResolver.ready())
|
||||
? HopResolver.resolve(hops, originLat, originLon, null, null, null)
|
||||
: {};
|
||||
|
||||
// Convert HopResolver's map format to the array format live.js expects: {key, pos, name, known}
|
||||
const raw = hops.map(hop => {
|
||||
@@ -2056,7 +1934,6 @@
|
||||
let lastPulse = performance.now();
|
||||
const pulseStart = lastPulse;
|
||||
function animatePulse(now) {
|
||||
if (!animLayer) return;
|
||||
if (now - pulseStart > 2000) {
|
||||
try { animLayer.removeLayer(ring); } catch {}
|
||||
return;
|
||||
@@ -2301,10 +2178,6 @@
|
||||
const startTime = performance.now();
|
||||
|
||||
function tick(now) {
|
||||
if (!animLayer || !pathsLayer) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
const elapsed = now - startTime;
|
||||
const t = Math.min(1, elapsed / DURATION_MS);
|
||||
const lat = from[0] + (to[0] - from[0]) * t;
|
||||
@@ -2349,11 +2222,6 @@
|
||||
// Fade out
|
||||
const fadeStart = performance.now();
|
||||
function fadeOut(now) {
|
||||
if (!animLayer || !pathsLayer) {
|
||||
charMarkers.length = 0;
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
const ft = Math.min(1, (now - fadeStart) / 300);
|
||||
if (ft >= 1) {
|
||||
for (const cm of charMarkers) try { animLayer.removeLayer(cm.marker); } catch {}
|
||||
@@ -2401,10 +2269,6 @@
|
||||
|
||||
let lastStep = performance.now();
|
||||
function animateLine(now) {
|
||||
if (!animLayer || !pathsLayer) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
const elapsed = now - lastStep;
|
||||
if (elapsed >= 33) {
|
||||
const ticks = Math.min(Math.floor(elapsed / 33), 4);
|
||||
@@ -2433,7 +2297,6 @@
|
||||
let fadeOp = mainOpacity;
|
||||
let lastFade = performance.now();
|
||||
function animateFade(now) {
|
||||
if (!pathsLayer) return;
|
||||
const fadeElapsed = now - lastFade;
|
||||
if (fadeElapsed >= 52) {
|
||||
const fadeTicks = Math.min(Math.floor(fadeElapsed / 52), 4);
|
||||
@@ -2505,7 +2368,7 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
`;
|
||||
@@ -2573,7 +2436,7 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
`;
|
||||
@@ -2651,7 +2514,6 @@
|
||||
if (_lcdClockInterval) { clearInterval(_lcdClockInterval); _lcdClockInterval = null; }
|
||||
if (_rateCounterInterval) { clearInterval(_rateCounterInterval); _rateCounterInterval = null; }
|
||||
if (_pruneInterval) { clearInterval(_pruneInterval); _pruneInterval = null; }
|
||||
if (_affinityInterval) { clearInterval(_affinityInterval); _affinityInterval = null; }
|
||||
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
||||
if (map) { map.remove(); map = null; }
|
||||
if (_onResize) {
|
||||
@@ -2684,7 +2546,7 @@
|
||||
packetCount = 0; activeAnims = 0;
|
||||
nodeActivity = {}; pktTimestamps = [];
|
||||
feedDedup.clear();
|
||||
VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1; VCR.replayGen = 0;
|
||||
VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1;
|
||||
}
|
||||
|
||||
let _themeRefreshHandler = null;
|
||||
|
||||
+13
-140
@@ -15,8 +15,6 @@
|
||||
let wsHandler = null;
|
||||
let heatLayer = null;
|
||||
let geoFilterLayer = null;
|
||||
let affinityLayer = null;
|
||||
let affinityData = null;
|
||||
let userHasMoved = false;
|
||||
let controlsCollapsed = false;
|
||||
|
||||
@@ -114,7 +112,6 @@
|
||||
<label for="mcNeighbors"><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
|
||||
<div id="mcNeighborRef" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Ref: <span id="mcNeighborRefName">—</span></div>
|
||||
<div id="mcNeighborHint" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Click a node marker to set the reference node</div>
|
||||
<label id="mcAffinityDebugLabel" for="mcAffinityDebug" style="display:none"><input type="checkbox" id="mcAffinityDebug"> 🔍 Affinity Debug</label>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Last Heard</legend>
|
||||
@@ -228,22 +225,6 @@
|
||||
renderMarkers();
|
||||
});
|
||||
|
||||
// Affinity Debug overlay toggle — shown only when debugAffinity config is on or localStorage override
|
||||
(function initAffinityDebug() {
|
||||
var label = document.getElementById('mcAffinityDebugLabel');
|
||||
var show = (window.CLIENT_CONFIG && window.CLIENT_CONFIG.debugAffinity) || localStorage.getItem('meshcore-affinity-debug') === 'true';
|
||||
if (show && label) label.style.display = '';
|
||||
var cb = document.getElementById('mcAffinityDebug');
|
||||
if (!cb) return;
|
||||
cb.addEventListener('change', function (e) {
|
||||
if (e.target.checked) {
|
||||
loadAffinityDebugOverlay();
|
||||
} else {
|
||||
clearAffinityOverlay();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// Hash Labels toggle
|
||||
const hashLabelEl = document.getElementById('mcHashLabels');
|
||||
if (hashLabelEl) {
|
||||
@@ -768,30 +749,21 @@
|
||||
selectedReferenceNode = pubkey;
|
||||
neighborPubkeys = new Set();
|
||||
try {
|
||||
// Use affinity-based neighbor API (server-side disambiguation) instead of
|
||||
// client-side path walking which fails on hash collisions (#484)
|
||||
const data = await api('/nodes/' + pubkey + '/neighbors?min_count=3');
|
||||
for (const n of (data.neighbors || [])) {
|
||||
if (n.pubkey) neighborPubkeys.add(n.pubkey);
|
||||
// For ambiguous edges, include all candidates (better to show extra than miss)
|
||||
if (n.candidates) n.candidates.forEach(function(c) { if (c.pubkey) neighborPubkeys.add(c.pubkey); });
|
||||
}
|
||||
// If affinity data is insufficient, fall back to client-side path walking
|
||||
if (neighborPubkeys.size === 0) {
|
||||
const pathData = await api('/nodes/' + pubkey + '/paths');
|
||||
const paths = pathData.paths || [];
|
||||
for (const p of paths) {
|
||||
const hops = p.hops || [];
|
||||
for (var i = 0; i < hops.length; i++) {
|
||||
if (hops[i].pubkey === pubkey) {
|
||||
if (i > 0 && hops[i - 1].pubkey) neighborPubkeys.add(hops[i - 1].pubkey);
|
||||
if (i < hops.length - 1 && hops[i + 1].pubkey) neighborPubkeys.add(hops[i + 1].pubkey);
|
||||
}
|
||||
const data = await api('/nodes/' + pubkey + '/paths');
|
||||
const paths = data.paths || [];
|
||||
for (const p of paths) {
|
||||
const hops = p.hops || [];
|
||||
// Find the reference node in the path; direct neighbors are adjacent hops
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
if (hops[i].pubkey === pubkey) {
|
||||
if (i > 0 && hops[i - 1].pubkey) neighborPubkeys.add(hops[i - 1].pubkey);
|
||||
if (i < hops.length - 1 && hops[i + 1].pubkey) neighborPubkeys.add(hops[i + 1].pubkey);
|
||||
}
|
||||
}
|
||||
// (Redundant block removed — the main loop above already handles first/last hops)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch neighbors for', pubkey, ':', e);
|
||||
console.warn('Failed to fetch neighbor paths for', pubkey, '— neighbor filter may be incomplete:', e);
|
||||
neighborPubkeys = new Set();
|
||||
}
|
||||
// Update sidebar UI
|
||||
@@ -807,17 +779,8 @@
|
||||
if (cb) cb.checked = true;
|
||||
renderMarkers();
|
||||
}
|
||||
// Event delegation for Show Neighbors links (avoids inline onclick / global function timing issues)
|
||||
document.addEventListener('click', function(e) {
|
||||
var link = e.target.closest('[data-show-neighbors]');
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
selectReferenceNode(link.dataset.pubkey, link.dataset.name);
|
||||
}
|
||||
});
|
||||
// Expose for testing
|
||||
// Expose for popup onclick
|
||||
window._mapSelectRefNode = selectReferenceNode;
|
||||
window._mapGetNeighborPubkeys = function() { return neighborPubkeys ? Array.from(neighborPubkeys) : []; };
|
||||
|
||||
function buildPopup(node) {
|
||||
const key = node.public_key ? truncate(node.public_key, 16) : '—';
|
||||
@@ -846,7 +809,7 @@
|
||||
</dl>
|
||||
<div style="margin-top:8px;clear:both;">
|
||||
<a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node →</a>
|
||||
${node.public_key ? ` · <a href="#" data-show-neighbors data-pubkey="${escapeHtml(node.public_key)}" data-name="${escapeHtml(node.name || 'Unknown')}" style="color:var(--accent);font-size:12px;">Show Neighbors</a>` : ''}
|
||||
${node.public_key ? ` · <a href="#" onclick="event.preventDefault();window._mapSelectRefNode('${safeEsc(node.public_key.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/</g, '\\x3c'))}','${safeEsc((node.name || 'Unknown').replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/</g, '\\x3c'))}')" style="color:var(--accent);font-size:12px;">Show Neighbors</a>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -876,7 +839,6 @@
|
||||
selectedReferenceNode = null;
|
||||
neighborPubkeys = null;
|
||||
delete window._mapSelectRefNode;
|
||||
delete window._mapGetNeighborPubkeys;
|
||||
}
|
||||
|
||||
function toggleHeatmap(on) {
|
||||
@@ -913,95 +875,6 @@
|
||||
|
||||
let _themeRefreshHandler = null;
|
||||
|
||||
// ─── Affinity Debug Overlay ────────────────────────────────────────────────
|
||||
function clearAffinityOverlay() {
|
||||
if (affinityLayer) { map.removeLayer(affinityLayer); affinityLayer = null; }
|
||||
affinityData = null;
|
||||
}
|
||||
|
||||
function loadAffinityDebugOverlay() {
|
||||
clearAffinityOverlay();
|
||||
// Fetch debug data — requires API key stored in localStorage
|
||||
var apiKey = localStorage.getItem('meshcore-api-key') || '';
|
||||
fetch('/api/debug/affinity', { headers: { 'X-API-Key': apiKey } })
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (data) {
|
||||
affinityData = data;
|
||||
renderAffinityOverlay();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.warn('[affinity-debug] Failed to load:', err);
|
||||
var cb = document.getElementById('mcAffinityDebug');
|
||||
if (cb) cb.checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
function renderAffinityOverlay() {
|
||||
if (!affinityData || !map) return;
|
||||
clearAffinityOverlay();
|
||||
affinityLayer = L.layerGroup();
|
||||
|
||||
// Build node position lookup from current markers
|
||||
var nodePos = {};
|
||||
nodes.forEach(function (n) {
|
||||
if (n.latitude && n.longitude) {
|
||||
nodePos[n.public_key.toLowerCase()] = [n.latitude, n.longitude];
|
||||
}
|
||||
});
|
||||
|
||||
var edges = affinityData.edges || [];
|
||||
edges.forEach(function (e) {
|
||||
var posA = nodePos[e.nodeA];
|
||||
var posB = e.nodeB ? nodePos[e.nodeB] : null;
|
||||
|
||||
if (!posA) return;
|
||||
|
||||
// Unresolved prefix — show ❓ marker near nodeA
|
||||
if (e.unresolved || (!posB && e.ambiguous)) {
|
||||
if (posA) {
|
||||
var marker = L.marker([posA[0] + 0.001, posA[1] + 0.001], {
|
||||
icon: L.divIcon({ html: '❓', className: 'affinity-unresolved', iconSize: [20, 20] })
|
||||
});
|
||||
marker.bindPopup('<b>Unresolved prefix:</b> ' + escapeHtml(e.prefix) + '<br>Observations: ' + e.weight);
|
||||
affinityLayer.addLayer(marker);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!posB) return;
|
||||
|
||||
// Color by confidence
|
||||
var color = '#ef4444'; // red — ambiguous
|
||||
var score = e.score || 0;
|
||||
if (score >= 0.6) color = '#22c55e'; // green — high
|
||||
else if (score >= 0.3) color = '#eab308'; // yellow — medium
|
||||
|
||||
// Thickness proportional to weight, clamped 1-5px
|
||||
var weight = Math.max(1, Math.min(5, Math.round((e.weight || 1) / 20)));
|
||||
|
||||
var line = L.polyline([posA, posB], {
|
||||
color: color,
|
||||
weight: weight,
|
||||
opacity: 0.7,
|
||||
dashArray: e.ambiguous ? '5,5' : null
|
||||
});
|
||||
|
||||
var popup = '<b>Affinity Edge</b><br>' +
|
||||
escapeHtml(e.nodeAName || e.nodeA.substring(0, 8)) + ' ↔ ' + escapeHtml(e.nodeBName || e.nodeB.substring(0, 8)) + '<br>' +
|
||||
'Observations: ' + e.observationCount + '<br>' +
|
||||
'Score: ' + (e.score || 0).toFixed(3) + '<br>' +
|
||||
'Last seen: ' + escapeHtml(e.lastSeen) + '<br>' +
|
||||
'Observers: ' + escapeHtml((e.observers || []).join(', '));
|
||||
if (e.avgSnr != null) popup += '<br>Avg SNR: ' + e.avgSnr.toFixed(1) + ' dB';
|
||||
|
||||
line.bindPopup(popup);
|
||||
affinityLayer.addLayer(line);
|
||||
});
|
||||
|
||||
affinityLayer.addTo(map);
|
||||
}
|
||||
// ─── End Affinity Debug ────────────────────────────────────────────────────
|
||||
|
||||
registerPage('map', {
|
||||
init: function(app, routeParam) {
|
||||
_themeRefreshHandler = () => { if (markerLayer) renderMarkers(); };
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
const nodeName = escapeHtml(n.name || n.public_key.slice(0, 12));
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="max-width:1000px;margin:0 auto;padding:12px 16px">
|
||||
<div style="max-width:1000px;margin:0 auto;padding:12px 16px;height:100%;overflow-y:auto">
|
||||
<div style="margin-bottom:12px">
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="color:var(--accent);text-decoration:none;font-size:12px">← Back to ${nodeName}</a>
|
||||
<h2 style="margin:4px 0 2px;font-size:18px">📊 ${nodeName} — Analytics</h2>
|
||||
|
||||
-229
@@ -175,114 +175,6 @@
|
||||
return `<div style="font-size:11px;color:var(--text-muted);margin:-2px 0 6px;padding:6px 10px;background:var(--surface-2);border-radius:4px;border-left:3px solid var(--status-yellow)">Adverts show varying hash sizes (<strong>${sizes.join('-byte, ')}-byte</strong>). This is a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">known bug</a> where automatic adverts ignore the configured multibyte path setting. Fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>.</div>`;
|
||||
}
|
||||
|
||||
// ─── Neighbor section helpers ───────────────────────────────────────────────
|
||||
|
||||
// Cache: pubkey → { data, ts }
|
||||
var _neighborCache = {};
|
||||
|
||||
function getConfidenceIndicator(entry) {
|
||||
if (entry.ambiguous) return { icon: '⚠️', label: 'AMBIGUOUS', cls: 'confidence-ambiguous' };
|
||||
if (entry.count <= 1) return { icon: '🔴', label: 'LOW', cls: 'confidence-low' };
|
||||
if (entry.score >= 0.5 && entry.count >= 3) return { icon: '🟢', label: 'HIGH', cls: 'confidence-high' };
|
||||
return { icon: '🟡', label: 'MEDIUM', cls: 'confidence-medium' };
|
||||
}
|
||||
|
||||
function renderNeighborRows(neighbors, limit) {
|
||||
var sorted = neighbors.slice().sort(function(a, b) {
|
||||
return (b.score || b.affinity || 0) - (a.score || a.affinity || 0);
|
||||
});
|
||||
var items = limit ? sorted.slice(0, limit) : sorted;
|
||||
return items.map(function(nb) {
|
||||
var conf = getConfidenceIndicator(nb);
|
||||
var name = nb.name || (nb.prefix + '… (unknown)');
|
||||
var nameHtml = nb.pubkey
|
||||
? '<a href="#/nodes/' + encodeURIComponent(nb.pubkey) + '">' + escapeHtml(name) + '</a>'
|
||||
: '<span class="text-muted">' + escapeHtml(name) + '</span>';
|
||||
var role = nb.role || '—';
|
||||
var roleBadge = nb.role
|
||||
? '<span class="badge" style="background:' + (ROLE_COLORS[nb.role] || 'var(--surface-2)') + ';color:#fff;font-size:10px">' + escapeHtml(role) + '</span>'
|
||||
: '<span class="text-muted">—</span>';
|
||||
var scoreTitle = 'Observations: ' + nb.count;
|
||||
if (nb.avg_snr != null) scoreTitle += ' · Avg SNR: ' + Number(nb.avg_snr).toFixed(1) + ' dB';
|
||||
var showOnMap = nb.pubkey
|
||||
? ' <button class="btn-link neighbor-show-map" data-pubkey="' + escapeHtml(nb.pubkey) + '" style="font-size:11px;padding:1px 6px;white-space:nowrap">📍 Map</button>'
|
||||
: '';
|
||||
return '<tr>' +
|
||||
'<td style="font-weight:600">' + nameHtml + '</td>' +
|
||||
'<td>' + roleBadge + '</td>' +
|
||||
'<td title="' + escapeHtml(scoreTitle) + '">' + Number(nb.score).toFixed(2) + '</td>' +
|
||||
'<td>' + nb.count + '</td>' +
|
||||
'<td>' + renderNodeTimestampHtml(nb.last_seen) + '</td>' +
|
||||
'<td><span title="' + conf.label + '">' + conf.icon + '</span></td>' +
|
||||
'<td style="text-align:right">' + showOnMap + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderNeighborTable(neighbors, limit) {
|
||||
return '<table class="data-table" style="font-size:12px">' +
|
||||
'<thead><tr><th>Neighbor</th><th>Role</th><th>Score</th><th>Obs</th><th>Last Seen</th><th>Conf</th><th></th></tr></thead>' +
|
||||
'<tbody>' + renderNeighborRows(neighbors, limit) + '</tbody></table>';
|
||||
}
|
||||
|
||||
function fetchAndRenderNeighbors(pubkey, containerId, opts) {
|
||||
opts = opts || {};
|
||||
var limit = opts.limit || 0;
|
||||
var headerSelector = opts.headerSelector;
|
||||
var viewAllPubkey = opts.viewAllPubkey;
|
||||
|
||||
// Always set spinner as initial DOM state (synchronous) so tests can observe it
|
||||
var spinnerEl = document.getElementById(containerId);
|
||||
if (spinnerEl) spinnerEl.innerHTML = '<div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div>';
|
||||
|
||||
// Check cache
|
||||
var cached = _neighborCache[pubkey];
|
||||
if (cached && (Date.now() - cached.ts < 300000)) { // 5 min cache
|
||||
renderNeighborData(cached.data, containerId, limit, headerSelector, viewAllPubkey);
|
||||
return;
|
||||
}
|
||||
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/neighbors', { ttl: CLIENT_TTL.nodeDetail }).then(function(data) {
|
||||
_neighborCache[pubkey] = { data: data, ts: Date.now() };
|
||||
renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey);
|
||||
}).catch(function() {
|
||||
var el = document.getElementById(containerId);
|
||||
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Could not load neighbor data</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey) {
|
||||
var el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
if (!data || !data.neighbors || !data.neighbors.length) {
|
||||
el.innerHTML = '<div class="text-muted" style="padding:8px">No neighbor data available yet. Neighbor relationships are built from observed packet paths over time.</div>';
|
||||
if (headerSelector) {
|
||||
var h = document.querySelector(headerSelector);
|
||||
if (h) h.textContent = 'Neighbors (0)';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (headerSelector) {
|
||||
var h = document.querySelector(headerSelector);
|
||||
if (h) h.textContent = 'Neighbors (' + data.neighbors.length + ')';
|
||||
}
|
||||
var html = renderNeighborTable(data.neighbors, limit);
|
||||
if (limit && data.neighbors.length > limit && viewAllPubkey) {
|
||||
html += '<div style="margin-top:6px;text-align:right"><a href="#/nodes/' + encodeURIComponent(viewAllPubkey) + '?section=node-neighbors" style="font-size:12px">View all ' + data.neighbors.length + ' neighbors →</a></div>';
|
||||
}
|
||||
el.innerHTML = html;
|
||||
|
||||
// Wire up "Show on Map" buttons via event delegation
|
||||
el.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.neighbor-show-map');
|
||||
if (!btn) return;
|
||||
var pk = btn.getAttribute('data-pubkey');
|
||||
if (pk) location.hash = '#/map?node=' + encodeURIComponent(pk);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── End neighbor helpers ─────────────────────────────────────────────────
|
||||
|
||||
let directNode = null; // set when navigating directly to #/nodes/:pubkey
|
||||
|
||||
let regionChangeHandler = null;
|
||||
@@ -455,18 +347,6 @@
|
||||
</table>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-full-card" id="node-neighbors">
|
||||
<h4 id="fullNeighborsHeader">Neighbors</h4>
|
||||
<div id="fullNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-full-card" id="node-affinity-debug" style="display:none">
|
||||
<h4 style="cursor:pointer" onclick="this.parentElement.querySelector('.affinity-debug-body').style.display=this.parentElement.querySelector('.affinity-debug-body').style.display==='none'?'block':'none'; this.querySelector('.toggle-icon').textContent=this.parentElement.querySelector('.affinity-debug-body').style.display==='none'?'▶':'▼'"><span class="toggle-icon">▶</span> 🔍 Affinity Debug</h4>
|
||||
<div class="affinity-debug-body" style="display:none">
|
||||
<div id="affinityDebugContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading debug data…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node-full-card" id="fullPathsSection">
|
||||
<h4>Paths Through This Node</h4>
|
||||
<div id="fullPathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
|
||||
@@ -547,103 +427,6 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Fetch neighbors for this node (full-screen view)
|
||||
fetchAndRenderNeighbors(n.public_key, 'fullNeighborsContent', {
|
||||
headerSelector: '#fullNeighborsHeader'
|
||||
});
|
||||
|
||||
// Affinity debug panel — show if debugAffinity is enabled
|
||||
(function loadAffinityDebug() {
|
||||
var show = (window.CLIENT_CONFIG && window.CLIENT_CONFIG.debugAffinity) || localStorage.getItem('meshcore-affinity-debug') === 'true';
|
||||
var panel = document.getElementById('node-affinity-debug');
|
||||
if (!show || !panel) return;
|
||||
panel.style.display = '';
|
||||
var apiKey = localStorage.getItem('meshcore-api-key') || '';
|
||||
fetch('/api/debug/affinity?node=' + encodeURIComponent(n.public_key), { headers: { 'X-API-Key': apiKey } })
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (data) {
|
||||
var el = document.getElementById('affinityDebugContent');
|
||||
if (!el) return;
|
||||
var html = '';
|
||||
|
||||
// Edges table
|
||||
if (data.edges && data.edges.length) {
|
||||
html += '<h5 style="margin:8px 0 4px">Neighbor Edges (' + data.edges.length + ')</h5>';
|
||||
html += '<table class="mini-table" style="width:100%;font-size:12px"><thead><tr><th>Neighbor</th><th>Score</th><th>Count</th><th>Last Seen</th><th>Observers</th><th>Status</th></tr></thead><tbody>';
|
||||
data.edges.forEach(function (e) {
|
||||
var neighbor = e.nodeBName || e.nodeAName || (e.nodeB || e.nodeA || '').substring(0, 8);
|
||||
if (e.nodeA.toLowerCase() === n.public_key.toLowerCase()) {
|
||||
neighbor = e.nodeBName || (e.nodeB || e.prefix || '?').substring(0, 8);
|
||||
} else {
|
||||
neighbor = e.nodeAName || (e.nodeA || '').substring(0, 8);
|
||||
}
|
||||
var status = e.ambiguous ? (e.unresolved ? '❓ Unresolved' : '⚠️ Ambiguous') : (e.resolved ? '✅ Auto-resolved' : '✅ Resolved');
|
||||
html += '<tr><td>' + escapeHtml(neighbor) + '</td><td>' + (e.score || 0).toFixed(3) + '</td><td>' + e.weight + '</td><td>' + (e.lastSeen || '').substring(0, 10) + '</td><td>' + (e.observers || []).length + '</td><td>' + status + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
} else {
|
||||
html += '<div class="text-muted" style="padding:8px">No affinity edges for this node</div>';
|
||||
}
|
||||
|
||||
// Resolutions
|
||||
if (data.resolutions && data.resolutions.length) {
|
||||
html += '<h5 style="margin:12px 0 4px">Prefix Resolutions (' + data.resolutions.length + ')</h5>';
|
||||
data.resolutions.forEach(function (r) {
|
||||
html += '<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;font-size:12px">';
|
||||
html += '<b>Prefix: ' + escapeHtml(r.prefix) + '</b> → ';
|
||||
if (r.method === 'auto-resolved') {
|
||||
html += '<span style="color:var(--status-green)">✅ ' + escapeHtml(r.chosenName || r.chosen || '?') + '</span>';
|
||||
html += ' (Jaccard=' + r.chosenJaccard.toFixed(2) + ', ratio=' + ((isFinite(r.ratio) && r.ratio < 100) ? r.ratio.toFixed(1) + '×' : '∞') + ')';
|
||||
} else {
|
||||
html += '<span style="color:var(--status-yellow)">⚠️ Ambiguous</span>';
|
||||
if (r.ratio) html += ' (ratio=' + r.ratio.toFixed(1) + '×, threshold=' + r.thresholdApplied + '×)';
|
||||
}
|
||||
// Show disambiguation tier used (M4 resolveWithContext)
|
||||
if (r.tier) {
|
||||
var tierLabels = {
|
||||
'neighbor_affinity': '🏘️ Affinity',
|
||||
'geo_proximity': '🌍 Geo',
|
||||
'gps_preference': '📍 GPS',
|
||||
'first_match': '🎲 Naive',
|
||||
'unique_prefix': '✓ Unique',
|
||||
'no_match': '∅ None'
|
||||
};
|
||||
html += ' <span style="font-size:11px;opacity:0.8">[tier: ' + (tierLabels[r.tier] || escapeHtml(r.tier)) + ']</span>';
|
||||
}
|
||||
// Candidates table
|
||||
if (r.candidates && r.candidates.length) {
|
||||
html += '<div style="margin-top:4px"><table class="mini-table" style="width:100%;font-size:11px"><thead><tr><th>Candidate</th><th>Jaccard</th><th>Count</th></tr></thead><tbody>';
|
||||
r.candidates.forEach(function (c) {
|
||||
var highlight = r.chosen && c.pubkey === r.chosen ? ' style="background:var(--status-green-bg,rgba(34,197,94,0.1))"' : '';
|
||||
html += '<tr' + highlight + '><td>' + escapeHtml(c.name || c.pubkey.substring(0, 8)) + '</td><td>' + c.jaccard.toFixed(3) + '</td><td>' + c.score + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Stats summary
|
||||
if (data.stats) {
|
||||
html += '<h5 style="margin:12px 0 4px">Graph Stats</h5>';
|
||||
html += '<div style="font-size:12px;line-height:1.6">';
|
||||
html += 'Total edges: ' + data.stats.totalEdges + '<br>';
|
||||
html += 'Total nodes: ' + data.stats.totalNodes + '<br>';
|
||||
html += 'Resolved: ' + data.stats.resolvedCount + ' | Ambiguous: ' + data.stats.ambiguousCount + ' | Unresolved: ' + data.stats.unresolvedCount + '<br>';
|
||||
html += 'Avg confidence: ' + (data.stats.avgConfidence || 0).toFixed(3) + '<br>';
|
||||
html += 'Cold-start coverage: ' + (data.stats.coldStartCoverage || 0).toFixed(1) + '%<br>';
|
||||
html += 'Cache age: ' + (data.stats.cacheAge || 'N/A') + ' | Last rebuild: ' + (data.stats.lastRebuild || 'N/A');
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
})
|
||||
.catch(function (err) {
|
||||
var el = document.getElementById('affinityDebugContent');
|
||||
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Failed to load debug data: ' + escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
})();
|
||||
|
||||
// Fetch paths through this node (full-screen view)
|
||||
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
|
||||
const el = document.getElementById('fullPathsContent');
|
||||
@@ -1036,11 +819,6 @@
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-detail-section" id="panelNeighborsSection">
|
||||
<h4 id="panelNeighborsHeader">Neighbors</h4>
|
||||
<div id="panelNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section" id="pathsSection">
|
||||
<h4>Paths Through This Node</h4>
|
||||
<div id="pathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
|
||||
@@ -1111,13 +889,6 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Fetch neighbors for this node (condensed panel — top 5)
|
||||
fetchAndRenderNeighbors(n.public_key, 'panelNeighborsContent', {
|
||||
limit: 5,
|
||||
headerSelector: '#panelNeighborsHeader',
|
||||
viewAllPubkey: n.public_key
|
||||
});
|
||||
|
||||
// Fetch paths through this node
|
||||
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
|
||||
const el = document.getElementById('pathsContent');
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="observer-detail-page" style="padding:16px">
|
||||
<div class="observer-detail-page" style="overflow-y:auto;height:calc(100vh - 56px);padding:16px">
|
||||
<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
|
||||
<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">←</a>
|
||||
<h2 style="margin:0" id="obsTitle">Observer Detail</h2>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/* === CoreScope — packet-helpers.js (shared packet utilities) === */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Cached JSON.parse helpers for packet data (issue #387).
|
||||
* Avoids repeated parsing of path_json / decoded_json on the same packet object.
|
||||
* Results are cached as _parsedPath / _parsedDecoded properties on the packet.
|
||||
*
|
||||
* Handles pre-parsed objects (non-string values) gracefully — returns them as-is.
|
||||
*/
|
||||
|
||||
window.getParsedPath = function getParsedPath(p) {
|
||||
if (p._parsedPath !== undefined) return p._parsedPath || [];
|
||||
var raw = p.path_json;
|
||||
if (typeof raw !== 'string') {
|
||||
p._parsedPath = Array.isArray(raw) ? raw : [];
|
||||
return p._parsedPath;
|
||||
}
|
||||
try { p._parsedPath = JSON.parse(raw) || []; } catch (e) { p._parsedPath = []; }
|
||||
return p._parsedPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear cached _parsedPath/_parsedDecoded from a packet object.
|
||||
* Must be called after spreading a parent packet into an observation/child,
|
||||
* otherwise the child inherits stale cached values from the parent (issue #504).
|
||||
*/
|
||||
window.clearParsedCache = function clearParsedCache(p) {
|
||||
delete p._parsedPath;
|
||||
delete p._parsedDecoded;
|
||||
delete p._parsedResolvedPath;
|
||||
return p;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse resolved_path (server-side resolved full pubkeys).
|
||||
* Returns array of pubkey strings (or null entries) if present, or null if absent.
|
||||
* Cached as _parsedResolvedPath on the packet object.
|
||||
*/
|
||||
window.getResolvedPath = function getResolvedPath(p) {
|
||||
if (p._parsedResolvedPath !== undefined) return p._parsedResolvedPath;
|
||||
var raw = p.resolved_path;
|
||||
if (!raw) { p._parsedResolvedPath = null; return null; }
|
||||
if (typeof raw !== 'string') {
|
||||
p._parsedResolvedPath = Array.isArray(raw) ? raw : null;
|
||||
return p._parsedResolvedPath;
|
||||
}
|
||||
try { p._parsedResolvedPath = JSON.parse(raw) || null; } catch (e) { p._parsedResolvedPath = null; }
|
||||
return p._parsedResolvedPath;
|
||||
};
|
||||
|
||||
window.getParsedDecoded = function getParsedDecoded(p) {
|
||||
if (p._parsedDecoded !== undefined) return p._parsedDecoded || {};
|
||||
var raw = p.decoded_json;
|
||||
if (typeof raw !== 'string') {
|
||||
p._parsedDecoded = (raw && typeof raw === 'object') ? raw : {};
|
||||
return p._parsedDecoded;
|
||||
}
|
||||
try { p._parsedDecoded = JSON.parse(raw) || {}; } catch (e) { p._parsedDecoded = {}; }
|
||||
return p._parsedDecoded;
|
||||
};
|
||||
+64
-143
@@ -43,17 +43,12 @@
|
||||
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
|
||||
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)">✕</button>';
|
||||
|
||||
// getParsedPath / getParsedDecoded are in shared packet-helpers.js (loaded before this file)
|
||||
const getParsedPath = window.getParsedPath;
|
||||
const getParsedDecoded = window.getParsedDecoded;
|
||||
|
||||
// --- Virtual scroll state ---
|
||||
const VSCROLL_ROW_HEIGHT = 36; // estimated row height in px
|
||||
const VSCROLL_BUFFER = 30; // extra rows above/below viewport
|
||||
let _displayPackets = []; // filtered packets for current view
|
||||
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 _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 _lastVisibleStart = -1; // last rendered start index (for dirty checking)
|
||||
let _lastVisibleEnd = -1; // last rendered end index (for dirty checking)
|
||||
@@ -171,29 +166,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-populate hopNameCache from server-side resolved_path on packets.
|
||||
* Packets with resolved_path skip client-side HopResolver entirely.
|
||||
* Must call ensureHopResolver() first so nodesList is available for name lookup.
|
||||
*/
|
||||
async function cacheResolvedPaths(packets) {
|
||||
if (!packets || !packets.length) return;
|
||||
let needsInit = false;
|
||||
for (const p of packets) {
|
||||
const rp = getResolvedPath(p);
|
||||
if (rp) { needsInit = true; break; }
|
||||
}
|
||||
if (!needsInit) return;
|
||||
await ensureHopResolver();
|
||||
for (const p of packets) {
|
||||
const rp = getResolvedPath(p);
|
||||
if (!rp) continue;
|
||||
const hops = getParsedPath(p);
|
||||
const resolved = HopResolver.resolveFromServer(hops, rp);
|
||||
Object.assign(hopNameCache, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
function renderHop(h, observerId) {
|
||||
// Use per-packet cache key if observer context available (ambiguous hops differ by region)
|
||||
const cacheKey = observerId ? h + ':' + observerId : h;
|
||||
@@ -292,8 +264,7 @@
|
||||
const obs = data.observations.find(o => String(o.id) === String(obsTarget));
|
||||
if (obs) {
|
||||
expandedHashes.add(h);
|
||||
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, resolved_path: obs.resolved_path, timestamp: obs.timestamp, first_seen: obs.timestamp};
|
||||
clearParsedCache(obsPacket);
|
||||
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, timestamp: obs.timestamp, first_seen: obs.timestamp};
|
||||
selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id);
|
||||
} else {
|
||||
selectPacket(data.packet.id, h, data);
|
||||
@@ -349,7 +320,7 @@
|
||||
panel.appendChild(content);
|
||||
const pkt = data.packet;
|
||||
try {
|
||||
const hops = getParsedPath(pkt);
|
||||
const hops = JSON.parse(pkt.path_json || '[]');
|
||||
const newHops = hops.filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
} catch {}
|
||||
@@ -381,7 +352,7 @@
|
||||
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.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.observer) { const obsSet = new Set(filters.observer.split(',')); if (!obsSet.has(p.observer_id)) return false; }
|
||||
if (filters.hash && p.hash !== filters.hash) return false;
|
||||
if (RegionFilter.getRegionParam()) {
|
||||
const selectedRegions = RegionFilter.getRegionParam().split(',');
|
||||
@@ -394,16 +365,9 @@
|
||||
if (!filtered.length) return;
|
||||
|
||||
// Resolve any new hops, then update and re-render
|
||||
// Pre-populate from server-side resolved_path, then fall back for remaining
|
||||
const newHops = new Set();
|
||||
for (const p of filtered) {
|
||||
const rp = getResolvedPath(p);
|
||||
const hops = getParsedPath(p);
|
||||
if (rp && rp.length === hops.length && window.HopResolver && HopResolver.ready()) {
|
||||
const resolved = HopResolver.resolveFromServer(hops, rp);
|
||||
Object.assign(hopNameCache, resolved);
|
||||
}
|
||||
try { hops.forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
|
||||
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
|
||||
}
|
||||
(newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => {
|
||||
if (groupByHash) {
|
||||
@@ -427,9 +391,6 @@
|
||||
existing._children.unshift(p);
|
||||
if (existing._children.length > 200) existing._children.length = 200;
|
||||
sortGroupChildren(existing);
|
||||
// Invalidate row counts — child count changed, so virtual scroll
|
||||
// heights are stale until next renderTableRows() (#410)
|
||||
_invalidateRowCounts();
|
||||
}
|
||||
} else {
|
||||
// New group
|
||||
@@ -476,7 +437,6 @@
|
||||
clearTimeout(_wsRenderTimer);
|
||||
_displayPackets = [];
|
||||
_rowCounts = [];
|
||||
_rowCountsDirty = false;
|
||||
_cumulativeOffsetsCache = null;
|
||||
_observerFilterSet = null;
|
||||
_lastVisibleStart = -1;
|
||||
@@ -523,7 +483,6 @@
|
||||
if (regionParam) params.set('region', regionParam);
|
||||
if (filters.hash) params.set('hash', filters.hash);
|
||||
if (filters.node) params.set('node', filters.node);
|
||||
if (filters.observer) params.set('observer', filters.observer);
|
||||
params.set('groupByHash', 'true'); // always fetch grouped
|
||||
|
||||
const data = await api('/packets?' + params.toString());
|
||||
@@ -538,7 +497,7 @@
|
||||
await Promise.all(multiObs.map(async (p) => {
|
||||
try {
|
||||
const d = await api(`/packets/${p.hash}`);
|
||||
if (d?.observations) p._children = d.observations.map(o => clearParsedCache({...d.packet, ...o, _isObservation: true}));
|
||||
if (d?.observations) p._children = d.observations.map(o => ({...d.packet, ...o, _isObservation: true}));
|
||||
} catch {}
|
||||
}));
|
||||
// Flatten: replace grouped packets with individual observations
|
||||
@@ -554,13 +513,10 @@
|
||||
totalCount = flat.length;
|
||||
}
|
||||
|
||||
// Pre-resolve from server-side resolved_path (preferred, no client-side disambiguation needed)
|
||||
await cacheResolvedPaths(packets);
|
||||
|
||||
// Pre-resolve all path hops to node names (fallback for packets without resolved_path)
|
||||
// Pre-resolve all path hops to node names
|
||||
const allHops = new Set();
|
||||
for (const p of packets) {
|
||||
try { getParsedPath(p).forEach(h => allHops.add(h)); } catch {}
|
||||
try { const path = JSON.parse(p.path_json || '[]'); path.forEach(h => allHops.add(h)); } catch {}
|
||||
}
|
||||
if (allHops.size) await resolveHops([...allHops]);
|
||||
|
||||
@@ -569,7 +525,7 @@
|
||||
for (const p of packets) {
|
||||
if (!p.observer_id) continue;
|
||||
try {
|
||||
const path = getParsedPath(p);
|
||||
const path = JSON.parse(p.path_json || '[]');
|
||||
const ambiguous = path.filter(h => hopNameCache[h]?.ambiguous);
|
||||
if (ambiguous.length) {
|
||||
if (!hopsByObserver[p.observer_id]) hopsByObserver[p.observer_id] = new Set();
|
||||
@@ -580,22 +536,19 @@
|
||||
// Ambiguous hops are already resolved by HopResolver client-side
|
||||
// No need for per-observer server API calls
|
||||
|
||||
// Restore expanded group children (parallel fetch, Map lookup)
|
||||
// Restore expanded group children
|
||||
if (groupByHash && expandedHashes.size > 0) {
|
||||
const expandedArr = [...expandedHashes];
|
||||
const results = await Promise.all(expandedArr.map(hash => {
|
||||
const group = hashIndex.get(hash);
|
||||
if (!group) return { hash, group: null, data: null };
|
||||
return api(`/packets?hash=${hash}&limit=20`)
|
||||
.then(data => ({ hash, group, data }))
|
||||
.catch(() => ({ hash, group, data: null }));
|
||||
}));
|
||||
for (const { hash, group, data } of results) {
|
||||
if (!group) {
|
||||
for (const hash of expandedHashes) {
|
||||
const group = packets.find(p => p.hash === hash);
|
||||
if (group) {
|
||||
try {
|
||||
const childData = await api(`/packets?hash=${hash}&limit=20`);
|
||||
group._children = childData.packets || [];
|
||||
sortGroupChildren(group);
|
||||
} catch {}
|
||||
} else {
|
||||
// Group no longer in results — remove from expanded
|
||||
expandedHashes.delete(hash);
|
||||
} else if (data) {
|
||||
group._children = data.packets || [];
|
||||
sortGroupChildren(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -880,7 +833,7 @@
|
||||
try {
|
||||
const data = await api(`/packets/${p.hash}`);
|
||||
if (data?.packet && data.observations) {
|
||||
p._children = data.observations.map(o => clearParsedCache({...data.packet, ...o, _isObservation: true}));
|
||||
p._children = data.observations.map(o => ({...data.packet, ...o, _isObservation: true}));
|
||||
p._fetchedData = data;
|
||||
}
|
||||
} catch {}
|
||||
@@ -893,7 +846,7 @@
|
||||
// Resolve any new hops from updated header paths
|
||||
const newHops = new Set();
|
||||
for (const p of packets) {
|
||||
try { getParsedPath(p).forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
|
||||
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
|
||||
}
|
||||
if (newHops.size) await resolveHops([...newHops]);
|
||||
renderTableRows();
|
||||
@@ -1048,12 +1001,11 @@
|
||||
}
|
||||
else if (action === 'select-observation') {
|
||||
const parentHash = row.dataset.parentHash;
|
||||
const group = hashIndex.get(parentHash);
|
||||
const group = packets.find(p => p.hash === parentHash);
|
||||
const child = group?._children?.find(c => String(c.id) === String(value));
|
||||
if (child) {
|
||||
const parentData = group._fetchedData;
|
||||
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, resolved_path: child.resolved_path, timestamp: child.timestamp, first_seen: child.timestamp} : child;
|
||||
if (parentData) { clearParsedCache(obsPacket); }
|
||||
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, timestamp: child.timestamp, first_seen: child.timestamp} : child;
|
||||
selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id);
|
||||
}
|
||||
}
|
||||
@@ -1107,7 +1059,7 @@
|
||||
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
|
||||
<td class="col-details">${getDetailPreview(getParsedDecoded(p))}</td>
|
||||
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
|
||||
</tr>`;
|
||||
if (isExpanded && p._children) {
|
||||
let visibleChildren = p._children;
|
||||
@@ -1120,7 +1072,8 @@
|
||||
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
|
||||
const childHashBytes = ((parseInt(c.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const childRegion = c.observer_id ? (observerMap.get(c.observer_id)?.iata || '') : '';
|
||||
const childPath = getParsedPath(c);
|
||||
let childPath = [];
|
||||
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
|
||||
const childPathStr = renderPath(childPath, c.observer_id);
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
@@ -1132,7 +1085,7 @@
|
||||
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${getDetailPreview(getParsedDecoded(c))}</td>
|
||||
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(c.decoded_json || '{}'); } catch { return {}; } })())}</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
@@ -1141,8 +1094,9 @@
|
||||
|
||||
// Build HTML for a single flat (ungrouped) packet row
|
||||
function buildFlatRowHtml(p) {
|
||||
const decoded = getParsedDecoded(p) || {};
|
||||
const pathHops = getParsedPath(p) || [];
|
||||
let decoded, pathHops = [];
|
||||
try { decoded = JSON.parse(p.decoded_json || '{}'); } catch {}
|
||||
try { pathHops = JSON.parse(p.path_json || '[]') || []; } catch {}
|
||||
const region = p.observer_id ? (observerMap.get(p.observer_id)?.iata || '') : '';
|
||||
const typeName = payloadTypeName(p.payload_type);
|
||||
const typeClass = payloadTypeColor(p.payload_type);
|
||||
@@ -1164,21 +1118,6 @@
|
||||
</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.
|
||||
// Used by both row counting and renderVisibleRows to avoid divergence (#424).
|
||||
function _getRowCount(p) {
|
||||
@@ -1217,9 +1156,6 @@
|
||||
const scrollContainer = document.getElementById('pktLeft');
|
||||
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
|
||||
const offsets = _cumulativeRowOffsets();
|
||||
const totalDomRows = offsets[offsets.length - 1];
|
||||
@@ -1351,11 +1287,7 @@
|
||||
}
|
||||
if (filters.observer) {
|
||||
const obsIds = new Set(filters.observer.split(','));
|
||||
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;
|
||||
});
|
||||
displayPackets = displayPackets.filter(p => obsIds.has(p.observer_id));
|
||||
}
|
||||
|
||||
// Packet Filter Language
|
||||
@@ -1376,7 +1308,6 @@
|
||||
if (!displayPackets.length) {
|
||||
_displayPackets = [];
|
||||
_rowCounts = [];
|
||||
_rowCountsDirty = false;
|
||||
_cumulativeOffsetsCache = null;
|
||||
_observerFilterSet = null;
|
||||
_lastVisibleStart = -1;
|
||||
@@ -1396,7 +1327,6 @@
|
||||
_displayGrouped = groupByHash;
|
||||
_observerFilterSet = filters.observer ? new Set(filters.observer.split(',')) : null;
|
||||
_rowCounts = displayPackets.map(p => _getRowCount(p));
|
||||
_rowCountsDirty = false;
|
||||
_cumulativeOffsetsCache = null;
|
||||
|
||||
attachVScrollListener();
|
||||
@@ -1484,7 +1414,7 @@
|
||||
// Resolve path hops for detail view
|
||||
const pkt = data.packet;
|
||||
try {
|
||||
const hops = getParsedPath(pkt);
|
||||
const hops = JSON.parse(pkt.path_json || '[]');
|
||||
const newHops = hops.filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
} catch {}
|
||||
@@ -1502,8 +1432,10 @@
|
||||
const pkt = data.packet;
|
||||
const breakdown = data.breakdown || {};
|
||||
const ranges = breakdown.ranges || [];
|
||||
const decoded = getParsedDecoded(pkt) || {};
|
||||
const pathHops = getParsedPath(pkt) || [];
|
||||
let decoded;
|
||||
try { decoded = JSON.parse(pkt.decoded_json); } catch { decoded = {}; }
|
||||
let pathHops;
|
||||
try { pathHops = JSON.parse(pkt.path_json || '[]') || []; } catch { pathHops = []; }
|
||||
|
||||
// Resolve sender GPS — from packet directly, or from known node in DB
|
||||
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
|
||||
@@ -1525,18 +1457,11 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Resolve hops: prefer server-side resolved_path, fall back to client-side HopResolver
|
||||
// Re-resolve hops using client-side HopResolver with sender GPS context
|
||||
if (pathHops.length) {
|
||||
try {
|
||||
const serverResolved = getResolvedPath(pkt);
|
||||
let resolved;
|
||||
if (serverResolved && serverResolved.length === pathHops.length) {
|
||||
await ensureHopResolver();
|
||||
resolved = HopResolver.resolveFromServer(pathHops, serverResolved);
|
||||
} else {
|
||||
await ensureHopResolver();
|
||||
resolved = HopResolver.resolve(pathHops);
|
||||
}
|
||||
await ensureHopResolver();
|
||||
const resolved = HopResolver.resolve(pathHops);
|
||||
if (resolved) {
|
||||
for (const [k, v] of Object.entries(resolved)) {
|
||||
hopNameCache[k] = v;
|
||||
@@ -1686,8 +1611,10 @@
|
||||
const replayPackets = [];
|
||||
if (obs.length > 1) {
|
||||
for (const o of obs) {
|
||||
const oPath = getParsedPath(o);
|
||||
const oDec = getParsedDecoded(o);
|
||||
let oPath;
|
||||
try { oPath = JSON.parse(o.path_json || '[]'); } catch { oPath = pathHops; }
|
||||
let oDec;
|
||||
try { oDec = JSON.parse(o.decoded_json || '{}'); } catch { oDec = decoded; }
|
||||
replayPackets.push({
|
||||
id: o.id, hash: pkt.hash, raw: o.raw_hex || pkt.raw_hex,
|
||||
_ts: new Date(o.timestamp).getTime(),
|
||||
@@ -1713,25 +1640,22 @@
|
||||
if (routeBtn && pathHops.length) {
|
||||
routeBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
// Prefer server-side resolved_path if available
|
||||
const serverResolved = getResolvedPath(pkt);
|
||||
let resolvedKeys;
|
||||
if (serverResolved && serverResolved.length === pathHops.length) {
|
||||
// Use server-resolved pubkeys, fall back to short prefix for null entries
|
||||
resolvedKeys = pathHops.map((h, i) => serverResolved[i] || h);
|
||||
} else {
|
||||
// Fall back to client-side HopResolver
|
||||
const senderLat = decoded.lat || decoded.latitude;
|
||||
const senderLon = decoded.lon || decoded.longitude;
|
||||
let obsLat = null, obsLon = null;
|
||||
const obsId = obsName(pkt.observer_id);
|
||||
await ensureHopResolver();
|
||||
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon, pkt.observer_id) };
|
||||
resolvedKeys = pathHops.map(h => {
|
||||
const r = data.resolved?.[h];
|
||||
return r?.pubkey || h;
|
||||
});
|
||||
// Anchor disambiguation from sender's location if known (e.g. ADVERT lat/lon)
|
||||
const senderLat = decoded.lat || decoded.latitude;
|
||||
const senderLon = decoded.lon || decoded.longitude;
|
||||
// Resolve observer position for backward-pass anchor
|
||||
let obsLat = null, obsLon = null;
|
||||
const obsId = obsName(pkt.observer_id);
|
||||
if (obsId && HopResolver.ready()) {
|
||||
// Try to find observer in nodes list by name — best effort
|
||||
}
|
||||
await ensureHopResolver();
|
||||
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon, pkt.observer_id) };
|
||||
// Pass full pubkeys (client-disambiguated) to map, falling back to short prefix
|
||||
const resolvedKeys = pathHops.map(h => {
|
||||
const r = data.resolved?.[h];
|
||||
return r?.pubkey || h;
|
||||
});
|
||||
// Build origin info for the sender node
|
||||
const origin = {};
|
||||
if (decoded.pubKey) origin.pubkey = decoded.pubKey;
|
||||
@@ -1992,7 +1916,7 @@
|
||||
let obsSortMode = localStorage.getItem('meshcore-obs-sort') || SORT_OBSERVER;
|
||||
|
||||
function getPathHopCount(c) {
|
||||
try { return getParsedPath(c).length; } catch { return 0; }
|
||||
try { return JSON.parse(c.path_json || '[]').length; } catch { return 0; }
|
||||
}
|
||||
|
||||
function sortGroupChildren(group) {
|
||||
@@ -2055,18 +1979,17 @@
|
||||
const data = await api(`/packets/${hash}`);
|
||||
const pkt = data.packet;
|
||||
if (!pkt) return;
|
||||
const group = hashIndex.get(hash);
|
||||
const group = packets.find(p => p.hash === hash);
|
||||
if (group && data.observations) {
|
||||
group._children = data.observations.map(o => clearParsedCache({...pkt, ...o, _isObservation: true}));
|
||||
group._children = data.observations.map(o => ({...pkt, ...o, _isObservation: true}));
|
||||
group._fetchedData = data;
|
||||
// Sort children based on current sort mode
|
||||
sortGroupChildren(group);
|
||||
}
|
||||
// Resolve hops from children: prefer server-side resolved_path
|
||||
await cacheResolvedPaths(group?._children || []);
|
||||
// Resolve any new hops from children
|
||||
const childHops = new Set();
|
||||
for (const c of (group?._children || [])) {
|
||||
try { getParsedPath(c).forEach(h => childHops.add(h)); } catch {}
|
||||
try { JSON.parse(c.path_json || '[]').forEach(h => childHops.add(h)); } catch {}
|
||||
}
|
||||
const newHops = [...childHops].filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
@@ -2116,8 +2039,6 @@
|
||||
renderPath,
|
||||
_getRowCount,
|
||||
_cumulativeRowOffsets,
|
||||
_invalidateRowCounts,
|
||||
_refreshRowCountsIfDirty,
|
||||
buildGroupRowHtml,
|
||||
buildFlatRowHtml,
|
||||
};
|
||||
@@ -2132,7 +2053,7 @@
|
||||
const data = await api(`/packets/${param}`);
|
||||
if (!data?.packet) { app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Packet not found</h2><p>Packet ${param} doesn't exist.</p><a href="#/packets">← Back to packets</a></div>`; return; }
|
||||
const hops = [];
|
||||
try { hops.push(...getParsedPath(data.packet)); } catch {}
|
||||
try { const ph = JSON.parse(data.packet.path_json || '[]'); hops.push(...ph); } catch {}
|
||||
const newHops = hops.filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
const container = document.createElement('div');
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
let interval = null;
|
||||
|
||||
async function render(app) {
|
||||
app.innerHTML = '<div id="perfWrapper" style="padding:16px 24px;"><h2>⚡ Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
|
||||
app.innerHTML = '<div id="perfWrapper" style="height:100%;overflow-y:auto;padding:16px 24px;"><h2>⚡ Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
|
||||
await refresh();
|
||||
}
|
||||
|
||||
|
||||
+21
-34
@@ -30,6 +30,10 @@
|
||||
--content-bg: var(--surface-0);
|
||||
--card-bg: var(--surface-1);
|
||||
--hover-bg: rgba(0,0,0, 0.04);
|
||||
--section-header-bg: rgba(243,139,168,0.18);
|
||||
--section-transport-bg: rgba(137,180,250,0.18);
|
||||
--section-path-bg: rgba(166,227,161,0.18);
|
||||
--section-payload-bg: rgba(249,226,175,0.18);
|
||||
}
|
||||
|
||||
/* ⚠️ DARK THEME VARIABLES — KEEP BOTH BLOCKS IN SYNC
|
||||
@@ -56,6 +60,10 @@
|
||||
--selected-bg: #1e3a5f;
|
||||
--hover-bg: rgba(255,255,255, 0.06);
|
||||
--section-bg: #1e1e34;
|
||||
--section-header-bg: rgba(243,139,168,0.15);
|
||||
--section-transport-bg: rgba(137,180,250,0.15);
|
||||
--section-path-bg: rgba(166,227,161,0.15);
|
||||
--section-payload-bg: rgba(249,226,175,0.15);
|
||||
}
|
||||
}
|
||||
/* ⚠️ DARK THEME VARIABLES — KEEP IN SYNC with @media block above */
|
||||
@@ -79,6 +87,10 @@
|
||||
--selected-bg: #1e3a5f;
|
||||
--hover-bg: rgba(255,255,255, 0.06);
|
||||
--section-bg: #1e1e34;
|
||||
--section-header-bg: rgba(243,139,168,0.15);
|
||||
--section-transport-bg: rgba(137,180,250,0.15);
|
||||
--section-path-bg: rgba(166,227,161,0.15);
|
||||
--section-payload-bg: rgba(249,226,175,0.15);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@@ -181,12 +193,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
}
|
||||
|
||||
/* === Layout === */
|
||||
/* Default: body-scroll mode — content pushes beyond viewport, iOS status-bar
|
||||
tap-to-scroll works because <body> is the scroll container. Pages that need
|
||||
a fixed-height container (maps, virtual-scroll, split-panels) add
|
||||
.app-fixed via the router so their children can use height:100%. */
|
||||
#app { min-height: calc(100vh - 52px); min-height: calc(100dvh - 52px); }
|
||||
#app.app-fixed { height: calc(100vh - 52px); height: calc(100dvh - 52px); min-height: 0; overflow: hidden; }
|
||||
#app { height: calc(100vh - 52px); height: calc(100dvh - 52px); overflow: hidden; }
|
||||
|
||||
.split-layout {
|
||||
display: flex; height: 100%; overflow: hidden;
|
||||
@@ -380,10 +387,10 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
background: var(--section-bg, #eef2ff); font-weight: 700; font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: .5px; color: var(--accent);
|
||||
}
|
||||
.field-table .section-header td { background: rgba(243,139,168,0.18); }
|
||||
.field-table .section-transport td { background: rgba(137,180,250,0.18); }
|
||||
.field-table .section-path td { background: rgba(166,227,161,0.18); }
|
||||
.field-table .section-payload td { background: rgba(249,226,175,0.18); }
|
||||
.field-table .section-header td { background: var(--section-header-bg); }
|
||||
.field-table .section-transport td { background: var(--section-transport-bg); }
|
||||
.field-table .section-path td { background: var(--section-path-bg); }
|
||||
.field-table .section-payload td { background: var(--section-payload-bg); }
|
||||
|
||||
/* === Path display === */
|
||||
.path-hops {
|
||||
@@ -635,15 +642,6 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 12px; margin-bottom: 8px;
|
||||
}
|
||||
/* Bug 7 fix: neighbor table text inherits accent color — force readable text */
|
||||
.node-detail-section .data-table td,
|
||||
.node-full-card .data-table td {
|
||||
color: var(--text);
|
||||
}
|
||||
.node-detail-section .data-table td a,
|
||||
.node-full-card .data-table td a {
|
||||
color: var(--accent);
|
||||
}
|
||||
.node-detail-section h4 {
|
||||
font-size: 12px; text-transform: uppercase; letter-spacing: .5px;
|
||||
color: var(--text-muted); margin-bottom: 8px; padding-bottom: 4px;
|
||||
@@ -679,7 +677,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.advert-info { font-size: 12px; line-height: 1.5; }
|
||||
|
||||
/* === Traces Page === */
|
||||
.traces-page { padding: 16px; max-width: var(--trace-max-width, 95vw); margin: 0 auto; }
|
||||
.traces-page { padding: 16px; max-width: var(--trace-max-width, 95vw); margin: 0 auto; overflow-y: auto; height: 100%; }
|
||||
.trace-search {
|
||||
display: flex; gap: 8px; margin-bottom: 20px;
|
||||
}
|
||||
@@ -751,7 +749,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* === Observers Page === */
|
||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; overflow-y: auto; height: calc(100vh - 56px); }
|
||||
.obs-summary { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.obs-stat { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-muted); }
|
||||
.health-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
@@ -952,9 +950,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.filter-bar { flex-direction: row; flex-wrap: wrap; gap: 4px; }
|
||||
.filter-toggle-btn { display: inline-flex !important; }
|
||||
.filter-bar > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: none; }
|
||||
/* 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 > * { display: inline-flex; }
|
||||
.filter-bar.filters-expanded > .col-toggle-wrap { display: inline-block; }
|
||||
.filter-bar.filters-expanded input { width: 100%; }
|
||||
.filter-bar.filters-expanded select { width: 100%; }
|
||||
@@ -1143,7 +1139,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.node-activity-time { color: var(--text-muted); white-space: nowrap; min-width: 70px; font-size: 12px; }
|
||||
|
||||
/* Analytics page */
|
||||
.analytics-page { padding: 16px 24px; max-width: 1600px; margin: 0 auto; }
|
||||
.analytics-page { padding: 16px 24px; max-width: 1600px; margin: 0 auto; overflow-y: auto; height: 100%; }
|
||||
.analytics-header { margin-bottom: 20px; }
|
||||
.analytics-header h2 { margin: 0 0 4px; }
|
||||
.analytics-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
@@ -1949,12 +1945,3 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.compare-select { min-width: auto; width: 100%; }
|
||||
.compare-summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Neighbor graph canvas focus indicator for keyboard navigation */
|
||||
#ngCanvas:focus {
|
||||
outline: 2px solid var(--link-color, #60a5fa);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
#ngCanvas:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -1,517 +0,0 @@
|
||||
/* Unit tests for customizer v2 core functions */
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
function makeSandbox() {
|
||||
const storage = {};
|
||||
const localStorage = {
|
||||
_data: storage,
|
||||
getItem(k) { return k in storage ? storage[k] : null; },
|
||||
setItem(k, v) { storage[k] = String(v); },
|
||||
removeItem(k) { delete storage[k]; },
|
||||
clear() { for (const k in storage) delete storage[k]; }
|
||||
};
|
||||
const ctx = {
|
||||
window: {
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
SITE_CONFIG: {},
|
||||
_SITE_CONFIG_ORIGINAL_HOME: null,
|
||||
},
|
||||
document: {
|
||||
readyState: 'loading',
|
||||
createElement: (tag) => ({
|
||||
id: '', textContent: '', innerHTML: '', className: '',
|
||||
setAttribute: () => {}, appendChild: () => {},
|
||||
style: {}, addEventListener: () => {},
|
||||
querySelectorAll: () => [], querySelector: () => null,
|
||||
}),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
documentElement: {
|
||||
style: { setProperty: () => {}, removeProperty: () => {}, getPropertyValue: () => '' },
|
||||
dataset: { theme: 'dark' },
|
||||
getAttribute: () => 'dark',
|
||||
},
|
||||
},
|
||||
console,
|
||||
localStorage,
|
||||
setTimeout: (fn) => fn(),
|
||||
clearTimeout: () => {},
|
||||
Date, Math, Array, Object, JSON, String, Number, Boolean,
|
||||
parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
|
||||
MutationObserver: class { observe() {} },
|
||||
HashChangeEvent: class {},
|
||||
CustomEvent: class CustomEvent { constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; } },
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
};
|
||||
ctx.window.localStorage = localStorage;
|
||||
ctx.self = ctx.window;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadCustomizer() {
|
||||
const ctx = makeSandbox();
|
||||
const code = fs.readFileSync('public/customize-v2.js', 'utf8');
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(code, ctx, { filename: 'customize-v2.js' });
|
||||
return { ctx, api: ctx.window._customizerV2, ls: ctx.localStorage };
|
||||
}
|
||||
|
||||
console.log('\n📋 Customizer V2 — Core Function Tests\n');
|
||||
|
||||
// ── readOverrides ──
|
||||
console.log('readOverrides:');
|
||||
test('returns {} when key is absent', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.readOverrides();
|
||||
assert.strictEqual(JSON.stringify(result), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains invalid JSON', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', 'not json{{{');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains a non-object (string)', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '"just a string"');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains an array', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '[1,2,3]');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains a number', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '42');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns parsed object when valid', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const data = { theme: { accent: '#ff0000' } };
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify(data));
|
||||
assert.deepStrictEqual(api.readOverrides(), data);
|
||||
});
|
||||
|
||||
// ── writeOverrides ──
|
||||
console.log('\nwriteOverrides:');
|
||||
test('writes serialized JSON to localStorage', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const data = { theme: { accent: '#ff0000' } };
|
||||
api.writeOverrides(data);
|
||||
assert.deepStrictEqual(JSON.parse(ls.getItem('cs-theme-overrides')), data);
|
||||
});
|
||||
|
||||
test('removes key when delta is empty {}', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '{"theme":{}}');
|
||||
api.writeOverrides({});
|
||||
assert.strictEqual(ls.getItem('cs-theme-overrides'), null);
|
||||
});
|
||||
|
||||
test('round-trips correctly (write → read = identical)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const data = { theme: { accent: '#abc', text: '#def' }, nodeColors: { repeater: '#111' } };
|
||||
api.writeOverrides(data);
|
||||
assert.deepStrictEqual(api.readOverrides(), data);
|
||||
});
|
||||
|
||||
test('strips invalid color values silently', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
api.writeOverrides({ theme: { accent: 'not-a-color' } });
|
||||
// Invalid color is stripped by _validateDelta; remaining empty object is stored as '{}'
|
||||
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored.theme, undefined);
|
||||
});
|
||||
|
||||
test('strips out-of-range opacity', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
api.writeOverrides({ heatmapOpacity: 1.5 });
|
||||
const stored1 = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored1.heatmapOpacity, undefined);
|
||||
api.writeOverrides({ heatmapOpacity: -0.1 });
|
||||
const stored2 = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored2.heatmapOpacity, undefined);
|
||||
});
|
||||
|
||||
test('accepts valid opacity', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
api.writeOverrides({ heatmapOpacity: 0.5 });
|
||||
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored.heatmapOpacity, 0.5);
|
||||
});
|
||||
|
||||
// ── computeEffective ──
|
||||
console.log('\ncomputeEffective:');
|
||||
test('returns server defaults when overrides is {}', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa', text: '#bbb' }, nodeColors: { repeater: '#ccc' } };
|
||||
const result = api.computeEffective(defaults, {});
|
||||
assert.deepStrictEqual(result, defaults);
|
||||
});
|
||||
|
||||
test('overrides a single key in a section', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa', text: '#bbb' } };
|
||||
const result = api.computeEffective(defaults, { theme: { accent: '#ff0000' } });
|
||||
assert.strictEqual(result.theme.accent, '#ff0000');
|
||||
assert.strictEqual(result.theme.text, '#bbb');
|
||||
});
|
||||
|
||||
test('overrides multiple keys across sections', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
|
||||
const result = api.computeEffective(defaults, { theme: { accent: '#111' }, nodeColors: { repeater: '#222' } });
|
||||
assert.strictEqual(result.theme.accent, '#111');
|
||||
assert.strictEqual(result.nodeColors.repeater, '#222');
|
||||
});
|
||||
|
||||
test('does not mutate either input', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa' } };
|
||||
const overrides = { theme: { accent: '#bbb' } };
|
||||
const defCopy = JSON.stringify(defaults);
|
||||
const ovrCopy = JSON.stringify(overrides);
|
||||
api.computeEffective(defaults, overrides);
|
||||
assert.strictEqual(JSON.stringify(defaults), defCopy);
|
||||
assert.strictEqual(JSON.stringify(overrides), ovrCopy);
|
||||
});
|
||||
|
||||
test('handles missing sections in overrides gracefully', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
|
||||
const result = api.computeEffective(defaults, { theme: { accent: '#ccc' } });
|
||||
assert.strictEqual(result.nodeColors.repeater, '#bbb');
|
||||
});
|
||||
|
||||
test('array values in home are fully replaced, not merged', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { home: { steps: [{ emoji: '1', title: 'a', description: 'b' }], heroTitle: 'X' } };
|
||||
const overrides = { home: { steps: [{ emoji: '2', title: 'c', description: 'd' }, { emoji: '3', title: 'e', description: 'f' }] } };
|
||||
const result = api.computeEffective(defaults, overrides);
|
||||
assert.strictEqual(result.home.steps.length, 2);
|
||||
assert.strictEqual(result.home.steps[0].emoji, '2');
|
||||
assert.strictEqual(result.home.heroTitle, 'X'); // untouched
|
||||
});
|
||||
|
||||
test('top-level scalars are directly replaced', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { heatmapOpacity: 0.5 };
|
||||
const result = api.computeEffective(defaults, { heatmapOpacity: 0.8 });
|
||||
assert.strictEqual(result.heatmapOpacity, 0.8);
|
||||
});
|
||||
|
||||
// ── validateShape ──
|
||||
console.log('\nvalidateShape:');
|
||||
test('accepts valid delta objects', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ theme: { accent: '#fff' }, heatmapOpacity: 0.5 });
|
||||
assert.strictEqual(result.valid, true);
|
||||
});
|
||||
|
||||
test('accepts empty object', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape({}).valid, true);
|
||||
});
|
||||
|
||||
test('rejects non-objects (string)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape('hello').valid, false);
|
||||
});
|
||||
|
||||
test('rejects non-objects (array)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape([1, 2]).valid, false);
|
||||
});
|
||||
|
||||
test('rejects non-objects (null)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape(null).valid, false);
|
||||
});
|
||||
|
||||
test('warns on unknown top-level keys', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ unknownKey: {} });
|
||||
// Unknown keys produce a console.warn but validateShape still returns valid
|
||||
assert.strictEqual(result.valid, true);
|
||||
assert.strictEqual(result.errors.length, 0);
|
||||
});
|
||||
|
||||
test('validates section types (rejects non-object section)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ theme: 'not an object' });
|
||||
assert.strictEqual(result.valid, false);
|
||||
});
|
||||
|
||||
test('accepts valid rgb() color values in theme', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ theme: { accent: 'rgb(1,2,3)' } });
|
||||
assert.strictEqual(result.valid, true);
|
||||
});
|
||||
|
||||
test('rejects out-of-range opacity values', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape({ heatmapOpacity: 2.0 }).valid, false);
|
||||
assert.strictEqual(api.validateShape({ liveHeatmapOpacity: -1 }).valid, false);
|
||||
});
|
||||
|
||||
// ── migrateOldKeys ──
|
||||
console.log('\nmigrateOldKeys:');
|
||||
test('migrates all 7 keys correctly', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, branding: { siteName: 'Test' } }));
|
||||
ls.setItem('meshcore-timestamp-mode', 'absolute');
|
||||
ls.setItem('meshcore-timestamp-timezone', 'utc');
|
||||
ls.setItem('meshcore-timestamp-format', 'iso-seconds');
|
||||
ls.setItem('meshcore-timestamp-custom-format', 'YYYY-MM-DD');
|
||||
ls.setItem('meshcore-heatmap-opacity', '0.7');
|
||||
ls.setItem('meshcore-live-heatmap-opacity', '0.3');
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result.theme.accent, '#f00');
|
||||
assert.strictEqual(result.branding.siteName, 'Test');
|
||||
assert.strictEqual(result.timestamps.defaultMode, 'absolute');
|
||||
assert.strictEqual(result.timestamps.timezone, 'utc');
|
||||
assert.strictEqual(result.heatmapOpacity, 0.7);
|
||||
assert.strictEqual(result.liveHeatmapOpacity, 0.3);
|
||||
// Legacy keys removed
|
||||
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
|
||||
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
|
||||
// New key written
|
||||
assert.notStrictEqual(ls.getItem('cs-theme-overrides'), null);
|
||||
});
|
||||
|
||||
test('handles partial migration (only some keys)', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-timestamp-mode', 'ago');
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result.timestamps.defaultMode, 'ago');
|
||||
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
|
||||
});
|
||||
|
||||
test('handles invalid JSON in meshcore-user-theme', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-user-theme', '{bad json');
|
||||
const result = api.migrateOldKeys();
|
||||
// Should not crash, returns delta (possibly empty besides what was valid)
|
||||
assert(result !== null);
|
||||
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
|
||||
});
|
||||
|
||||
test('skips migration if cs-theme-overrides already exists', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '{"theme":{}}');
|
||||
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' } }));
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result, null);
|
||||
// Legacy key NOT removed (migration skipped entirely)
|
||||
assert.notStrictEqual(ls.getItem('meshcore-user-theme'), null);
|
||||
});
|
||||
|
||||
test('returns null when no legacy keys found', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.migrateOldKeys(), null);
|
||||
});
|
||||
|
||||
test('drops unknown keys from meshcore-user-theme', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, unknownStuff: 'hi' }));
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result.theme.accent, '#f00');
|
||||
assert.strictEqual(result.unknownStuff, undefined);
|
||||
});
|
||||
|
||||
// ── THEME_CSS_MAP completeness ──
|
||||
console.log('\nTHEME_CSS_MAP:');
|
||||
test('includes surface3 mapping', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.THEME_CSS_MAP.surface3, '--surface-3');
|
||||
});
|
||||
|
||||
test('includes sectionBg mapping', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.THEME_CSS_MAP.sectionBg, '--section-bg');
|
||||
});
|
||||
|
||||
test('matches all keys from old app.js varMap', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const expectedKeys = [
|
||||
'accent', 'accentHover', 'navBg', 'navBg2', 'navText', 'navTextMuted',
|
||||
'background', 'text', 'textMuted', 'border',
|
||||
'statusGreen', 'statusYellow', 'statusRed',
|
||||
'surface1', 'surface2', 'surface3',
|
||||
'cardBg', 'contentBg', 'inputBg',
|
||||
'rowStripe', 'rowHover', 'detailBg',
|
||||
'selectedBg', 'sectionBg',
|
||||
'font', 'mono'
|
||||
];
|
||||
for (const key of expectedKeys) {
|
||||
assert(key in api.THEME_CSS_MAP, `Missing key: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── _isOverridden tests ──
|
||||
console.log('\n_isOverridden (value comparison):');
|
||||
|
||||
test('returns false when no overrides exist', () => {
|
||||
const { api } = loadCustomizer();
|
||||
api.init({ theme: { accent: '#aaa' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
|
||||
});
|
||||
|
||||
test('returns false when override matches server default', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#aaa' } }));
|
||||
api.init({ theme: { accent: '#aaa' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
|
||||
});
|
||||
|
||||
test('returns true when override differs from server default', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
||||
api.init({ theme: { accent: '#aaa' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
|
||||
});
|
||||
|
||||
test('returns false for key not in overrides', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
||||
api.init({ theme: { accent: '#aaa', border: '#ccc' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'border'), false);
|
||||
});
|
||||
|
||||
test('returns true when server has no default for overridden key', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
||||
api.init({});
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
|
||||
});
|
||||
|
||||
// ── Bug #518 Fixes ──
|
||||
|
||||
test('phantom overrides cleaned on init — matching scalars removed', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' }, typeColors: { ADVERT: '#22c55e' } };
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#4a9eff' }, typeColors: { ADVERT: '#22c55e' } }));
|
||||
api.init(server);
|
||||
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
||||
assert.ok(!delta.theme, 'phantom theme override should be cleaned');
|
||||
assert.ok(!delta.typeColors, 'phantom typeColors override should be cleaned');
|
||||
});
|
||||
|
||||
test('phantom overrides cleaned on init — matching arrays removed', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do it' }] } };
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do it' }] } }));
|
||||
api.init(server);
|
||||
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
||||
assert.ok(!delta.home, 'phantom home array override should be cleaned');
|
||||
});
|
||||
|
||||
test('real overrides preserved after init cleanup', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const server = { theme: { accent: '#4a9eff' } };
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } }));
|
||||
api.init(server);
|
||||
const delta = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(delta.theme.accent, '#ff0000');
|
||||
});
|
||||
|
||||
test('isOverridden handles array comparison via JSON.stringify', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } };
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } }));
|
||||
api.init(server);
|
||||
assert.strictEqual(api.isOverridden('home', 'steps'), false, 'matching array should not be overridden');
|
||||
});
|
||||
|
||||
test('isOverridden returns true for differing arrays', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } };
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '🚀', title: 'New', description: 'Changed' }] } }));
|
||||
api.init(server);
|
||||
assert.strictEqual(api.isOverridden('home', 'steps'), true, 'differing array should be overridden');
|
||||
});
|
||||
|
||||
test('setOverride prunes value matching server default', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const server = { theme: { accent: '#4a9eff' } };
|
||||
api.init(server);
|
||||
api.setOverride('theme', 'accent', '#4a9eff');
|
||||
// debounce fires synchronously in sandbox
|
||||
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
||||
assert.ok(!delta.theme || !delta.theme.accent, 'matching value should be pruned after setOverride');
|
||||
});
|
||||
|
||||
// ── Fix #2: _cleanPhantomOverrides when server has no section ──
|
||||
|
||||
test('phantom overrides cleaned when server has NO home section', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
// Server has theme but NO home — the common deployment case
|
||||
const server = { theme: { accent: '#4a9eff' } };
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { checklist: [], steps: [] } }));
|
||||
api.init(server);
|
||||
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
||||
assert.ok(!delta.home, 'phantom home override should be removed when server has no home section');
|
||||
});
|
||||
|
||||
test('phantom overrides cleaned when server section is undefined — empty arrays removed', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const server = { theme: { accent: '#4a9eff' }, nodeColors: { repeater: '#dc2626' } };
|
||||
// timestamps has actual values (not phantom), home has empty arrays (phantom)
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({
|
||||
timestamps: { defaultMode: 'ago', timezone: 'local' },
|
||||
home: { checklist: [], steps: [] }
|
||||
}));
|
||||
api.init(server);
|
||||
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
||||
assert.ok(!delta.home, 'phantom home with empty arrays should be removed');
|
||||
// timestamps has non-empty values — preserved even without server section
|
||||
assert.ok(delta.timestamps, 'timestamps with actual values should be preserved');
|
||||
assert.strictEqual(delta.timestamps.defaultMode, 'ago');
|
||||
});
|
||||
|
||||
// ── Fix #4: setOverride with value matching server default is NOT stored ──
|
||||
|
||||
test('setOverride with value matching server default is not stored', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' } };
|
||||
api.init(server);
|
||||
// Set override to same value as server default
|
||||
api.setOverride('theme', 'accent', '#4a9eff');
|
||||
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
||||
assert.ok(!delta.theme || !delta.theme.accent, 'value matching server default should not be stored');
|
||||
});
|
||||
|
||||
test('existing user overrides are NOT pruned by setOverride on other keys', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' } };
|
||||
// User previously chose a custom accent (different from server default)
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } }));
|
||||
api.init(server);
|
||||
// Now user changes border — accent should be preserved
|
||||
api.setOverride('theme', 'border', '#00ff00');
|
||||
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
||||
assert.strictEqual(delta.theme.accent, '#ff0000', 'pre-existing custom override should be preserved');
|
||||
assert.strictEqual(delta.theme.border, '#00ff00', 'new non-matching override should be stored');
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
+51
-605
@@ -85,7 +85,7 @@ async function run() {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
localStorage.removeItem('meshcore-user-theme');
|
||||
window.SITE_CONFIG = window.SITE_CONFIG || {};
|
||||
window.SITE_CONFIG.home = {
|
||||
heroTitle: 'Server Hero (E2E)',
|
||||
@@ -122,18 +122,18 @@ async function run() {
|
||||
const homeTab = page.locator('.cust-tab[data-tab="home"]');
|
||||
await homeTab.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await homeTab.click();
|
||||
const heroInput = page.locator('[data-cv2-field="home.heroTitle"]');
|
||||
const heroInput = page.locator('#cust-heroTitle');
|
||||
if (await heroInput.count() === 0) {
|
||||
console.log(' ⏭️ home.heroTitle input not found — TODO: requires running server');
|
||||
console.log(' ⏭️ #cust-heroTitle not found — TODO: requires running server');
|
||||
return;
|
||||
}
|
||||
await heroInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await heroInput.fill(editedHero);
|
||||
await page.waitForTimeout(700); // debounce is 300ms, allow margin
|
||||
await page.waitForTimeout(700); // autoSave debounce is 500ms
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
const persistedHero = await page.evaluate(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
const saved = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}');
|
||||
return saved && saved.home ? saved.home.heroTitle : '';
|
||||
} catch {
|
||||
return '';
|
||||
@@ -543,6 +543,51 @@ async function run() {
|
||||
assert(hasChannelHash, 'Undecrypted GRP_TXT detail should show "Channel Hash"');
|
||||
});
|
||||
|
||||
// --- Group: Hex breakdown colors (#329) ---
|
||||
|
||||
await test('Packet detail hex dump shows color-coded sections', async () => {
|
||||
// Find any packet with raw_hex via API
|
||||
const hash = await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/packets?limit=100');
|
||||
const data = await res.json();
|
||||
for (const p of (data.packets || [])) {
|
||||
if (p.raw_hex && p.raw_hex.length > 10) return p.hash;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
});
|
||||
if (!hash) { console.log(' ⏭️ Skipped (no packets with raw_hex found)'); return; }
|
||||
await page.goto(`${BASE}/#/packets/${hash}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => {
|
||||
const panel = document.getElementById('pktRight');
|
||||
if (!panel || panel.classList.contains('empty')) return false;
|
||||
return panel.textContent.length > 50 && !panel.textContent.includes('Loading');
|
||||
}, { timeout: 8000 });
|
||||
// Verify hex dump has colored spans (not monochrome)
|
||||
const coloredSpans = await page.$$eval('.hex-dump span[class*="hex-"]', els => els.length);
|
||||
assert(coloredSpans > 0, 'Hex dump should have color-coded spans with hex-* classes');
|
||||
});
|
||||
|
||||
await test('Packet detail shows hex legend with color swatches', async () => {
|
||||
// Re-use the packet detail page from previous test
|
||||
const legendItems = await page.$$eval('.hex-legend .legend-item, .hex-legend span', els => els.length);
|
||||
assert(legendItems > 0, 'Hex legend should show color swatch items');
|
||||
});
|
||||
|
||||
await test('Field breakdown table has tinted section rows', async () => {
|
||||
// Check that section-row elements have per-section color classes
|
||||
const sectionClasses = await page.$$eval('.field-table .section-row', rows =>
|
||||
rows.map(r => r.className)
|
||||
);
|
||||
assert(sectionClasses.length > 0, 'Field table should have section rows');
|
||||
const hasTinted = sectionClasses.some(c =>
|
||||
c.includes('section-header') || c.includes('section-transport') ||
|
||||
c.includes('section-path') || c.includes('section-payload')
|
||||
);
|
||||
assert(hasTinted, 'Section rows should have tinted color classes (section-header, section-path, etc.)');
|
||||
});
|
||||
|
||||
// --- Group: Analytics page (test 8 + sub-tabs) ---
|
||||
|
||||
// Test 8: Analytics page loads with overview
|
||||
@@ -550,7 +595,7 @@ async function run() {
|
||||
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#analyticsTabs');
|
||||
const tabs = await page.$$('#analyticsTabs .tab-btn');
|
||||
assert(tabs.length >= 10, `Expected >=10 analytics tabs, got ${tabs.length}`);
|
||||
assert(tabs.length >= 8, `Expected >=8 analytics tabs, got ${tabs.length}`);
|
||||
// Overview tab should be active by default and show stat cards
|
||||
await page.waitForSelector('#analyticsContent .stat-card', { timeout: 8000 });
|
||||
const cards = await page.$$('#analyticsContent .stat-card');
|
||||
@@ -624,53 +669,6 @@ async function run() {
|
||||
assert(content.length > 10, 'Distance tab should render content');
|
||||
});
|
||||
|
||||
await test('Analytics Neighbor Graph tab renders canvas and stats', async () => {
|
||||
await page.click('[data-tab="neighbor-graph"]');
|
||||
await page.waitForSelector('#ngCanvas', { timeout: 8000 });
|
||||
const hasCanvas = await page.$('#ngCanvas');
|
||||
assert(hasCanvas, 'Neighbor Graph tab should have a canvas element');
|
||||
const hasStats = await page.$$eval('#ngStats .stat-card', els => els.length);
|
||||
assert(hasStats >= 3, `Neighbor Graph stats should have >=3 cards, got ${hasStats}`);
|
||||
// Verify filters exist
|
||||
const hasSlider = await page.$('#ngMinScore');
|
||||
assert(hasSlider, 'Should have min score slider');
|
||||
const hasConfidence = await page.$('#ngConfidence');
|
||||
assert(hasConfidence, 'Should have confidence filter');
|
||||
});
|
||||
|
||||
await test('Analytics Neighbor Graph filter changes update stats', async () => {
|
||||
// Capture edge count before filter
|
||||
const edgesBefore = await page.$eval('#ngStats', el => {
|
||||
const cards = el.querySelectorAll('.stat-card');
|
||||
for (const c of cards) {
|
||||
if (c.textContent.toLowerCase().includes('edge')) {
|
||||
const m = c.textContent.match(/\d+/);
|
||||
if (m) return parseInt(m[0], 10);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
// Set min score slider to high value to reduce edges
|
||||
await page.$eval('#ngMinScore', el => { el.value = 90; el.dispatchEvent(new Event('input')); });
|
||||
await page.waitForTimeout(300);
|
||||
const edgesAfter = await page.$eval('#ngStats', el => {
|
||||
const cards = el.querySelectorAll('.stat-card');
|
||||
for (const c of cards) {
|
||||
if (c.textContent.toLowerCase().includes('edge')) {
|
||||
const m = c.textContent.match(/\d+/);
|
||||
if (m) return parseInt(m[0], 10);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
assert(edgesBefore >= 0, 'Should find edge count in stats before filter');
|
||||
assert(edgesAfter >= 0, 'Should find edge count in stats after filter');
|
||||
assert(edgesAfter <= edgesBefore, `Raising min score should reduce (or keep) edge count: ${edgesBefore} → ${edgesAfter}`);
|
||||
// Reset slider
|
||||
await page.$eval('#ngMinScore', el => { el.value = 0; el.dispatchEvent(new Event('input')); });
|
||||
await page.waitForTimeout(200);
|
||||
});
|
||||
|
||||
// --- Group: Compare page ---
|
||||
|
||||
await test('Compare page loads with observer dropdowns', async () => {
|
||||
@@ -1062,558 +1060,6 @@ async function run() {
|
||||
assert(hexDump, 'Hex dump should be visible after selecting a packet');
|
||||
});
|
||||
|
||||
// --- Group: Customizer v2 E2E tests ---
|
||||
|
||||
await test('Customizer v2: setOverride persists and applies CSS', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
// Force light mode — CI headless browsers may default to dark mode,
|
||||
// and in dark mode themeDark.accent overwrites theme.accent in applyCSS
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('meshcore-theme', 'light');
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
});
|
||||
// Clear any existing overrides
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
// Wait for init() to complete (server config fetch + full pipeline) before
|
||||
// setting override, so _runPipeline from init doesn't overwrite our value.
|
||||
await page.waitForFunction(() => {
|
||||
return window._customizerV2 && window._customizerV2.initDone;
|
||||
}, { timeout: 5000 });
|
||||
// Set an override via the API
|
||||
const result = await page.evaluate(() => {
|
||||
window._customizerV2.setOverride('theme', 'accent', '#ff0000');
|
||||
// Wait for debounce (300ms) + buffer
|
||||
return new Promise(resolve => setTimeout(() => {
|
||||
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
const cssVal = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
|
||||
resolve({ stored, cssVal });
|
||||
}, 500));
|
||||
});
|
||||
assert(result.stored.theme && result.stored.theme.accent === '#ff0000',
|
||||
'Override not persisted to localStorage');
|
||||
assert(result.cssVal === '#ff0000',
|
||||
`CSS variable --accent expected #ff0000 but got "${result.cssVal}"`);
|
||||
// Cleanup
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: clearOverride resets to server default', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
// Force light mode for consistent CSS testing
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('meshcore-theme', 'light');
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
});
|
||||
// Wait for init() to complete so _serverDefaults is populated
|
||||
await page.waitForFunction(() => {
|
||||
return window._customizerV2 && window._customizerV2.initDone;
|
||||
}, { timeout: 5000 });
|
||||
const result = await page.evaluate(() => {
|
||||
// Set the server default accent
|
||||
window._customizerV2.setOverride('theme', 'accent', '#ff0000');
|
||||
return new Promise(resolve => setTimeout(() => {
|
||||
window._customizerV2.clearOverride('theme', 'accent');
|
||||
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
const hasAccent = stored.theme && stored.theme.hasOwnProperty('accent');
|
||||
resolve({ hasAccent });
|
||||
}, 500));
|
||||
});
|
||||
assert(!result.hasAccent, 'accent should be removed from overrides after clearOverride');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: full reset clears all overrides', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' }, nodeColors: { repeater: '#00ff00' } }));
|
||||
// Simulate full reset
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
const stored = localStorage.getItem('cs-theme-overrides');
|
||||
return { stored };
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.stored === null, 'cs-theme-overrides should be null after full reset');
|
||||
});
|
||||
|
||||
await test('Customizer v2: export produces valid JSON', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
// Set some overrides
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#123456' } }));
|
||||
const delta = window._customizerV2.readOverrides();
|
||||
const json = JSON.stringify(delta, null, 2);
|
||||
try { JSON.parse(json); return { valid: true, hasAccent: delta.theme && delta.theme.accent === '#123456' }; }
|
||||
catch { return { valid: false }; }
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.valid, 'Exported JSON must be valid');
|
||||
assert(result.hasAccent, 'Exported JSON must contain the stored override');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: import applies overrides', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
const importData = { theme: { accent: '#abcdef' }, nodeColors: { repeater: '#112233' } };
|
||||
const validation = window._customizerV2.validateShape(importData);
|
||||
if (!validation.valid) return { error: 'Validation failed: ' + validation.errors.join(', ') };
|
||||
window._customizerV2.writeOverrides(importData);
|
||||
const stored = window._customizerV2.readOverrides();
|
||||
return { accent: stored.theme && stored.theme.accent, repeater: stored.nodeColors && stored.nodeColors.repeater };
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.accent === '#abcdef', 'Imported accent should be #abcdef');
|
||||
assert(result.repeater === '#112233', 'Imported repeater should be #112233');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: migration from legacy keys', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
// Clear new key so migration can run
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
// Set legacy keys
|
||||
localStorage.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#aabb01' }, branding: { siteName: 'LegacyName' } }));
|
||||
localStorage.setItem('meshcore-timestamp-mode', 'absolute');
|
||||
localStorage.setItem('meshcore-heatmap-opacity', '0.5');
|
||||
// Run migration
|
||||
const migrated = window._customizerV2.migrateOldKeys();
|
||||
const stored = window._customizerV2.readOverrides();
|
||||
const legacyGone = localStorage.getItem('meshcore-user-theme') === null &&
|
||||
localStorage.getItem('meshcore-timestamp-mode') === null &&
|
||||
localStorage.getItem('meshcore-heatmap-opacity') === null;
|
||||
return {
|
||||
migrated: !!migrated,
|
||||
accent: stored.theme && stored.theme.accent,
|
||||
siteName: stored.branding && stored.branding.siteName,
|
||||
tsMode: stored.timestamps && stored.timestamps.defaultMode,
|
||||
opacity: stored.heatmapOpacity,
|
||||
legacyGone
|
||||
};
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.migrated, 'migrateOldKeys should return non-null');
|
||||
assert(result.accent === '#aabb01', 'Theme accent should be migrated');
|
||||
assert(result.siteName === 'LegacyName', 'Branding should be migrated');
|
||||
assert(result.tsMode === 'absolute', 'Timestamp mode should be migrated');
|
||||
assert(result.opacity === 0.5, 'Heatmap opacity should be migrated');
|
||||
assert(result.legacyGone, 'Legacy keys should be removed after migration');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: browser-local banner visible', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
// Open customizer
|
||||
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
|
||||
const btn = await page.$(toggleSel);
|
||||
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
|
||||
await btn.click();
|
||||
await page.waitForSelector('.cv2-local-banner', { timeout: 5000 });
|
||||
const bannerText = await page.$eval('.cv2-local-banner', el => el.textContent);
|
||||
assert(bannerText.includes('browser only'), `Banner should mention "browser only" but got "${bannerText}"`);
|
||||
});
|
||||
|
||||
await test('Customizer v2: auto-save status indicator', async () => {
|
||||
// Panel should already be open from previous test
|
||||
const statusEl = await page.$('#cv2-save-status');
|
||||
if (!statusEl) { console.log(' ⏭️ Save status element not found'); return; }
|
||||
const statusText = await page.$eval('#cv2-save-status', el => el.textContent);
|
||||
assert(statusText.includes('saved') || statusText.includes('Saving'),
|
||||
`Status should show save state but got "${statusText}"`);
|
||||
});
|
||||
|
||||
await test('Customizer v2: override indicator appears and disappears', async () => {
|
||||
// Set override BEFORE page load so _renderTheme sees it during init
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => {
|
||||
// Force light mode so theme tab renders 'theme' section (not 'themeDark')
|
||||
localStorage.setItem('meshcore-theme', 'light');
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } }));
|
||||
});
|
||||
// Reload so customizer v2 initializes with the override in place
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
// Ensure light mode is active (CI headless may default to dark)
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
return { ok: true };
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
// Open customizer and check for override dot
|
||||
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
|
||||
const btn = await page.$(toggleSel);
|
||||
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
|
||||
await btn.click();
|
||||
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
|
||||
// Click theme tab
|
||||
const themeTab = await page.$('.cust-tab[data-tab="theme"]');
|
||||
if (themeTab) await themeTab.click();
|
||||
await page.waitForTimeout(200);
|
||||
// Check for override dot
|
||||
const dots = await page.$$('.cv2-override-dot');
|
||||
assert(dots.length > 0, 'Override dot should be visible when overrides exist');
|
||||
// Clear overrides and reload to verify dots disappear
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const btn2 = await page.$(toggleSel);
|
||||
if (btn2) await btn2.click();
|
||||
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
|
||||
const themeTab2 = await page.$('.cust-tab[data-tab="theme"]');
|
||||
if (themeTab2) await themeTab2.click();
|
||||
await page.waitForTimeout(200);
|
||||
const dotsAfter = await page.$$('.cv2-override-dot');
|
||||
assert(dotsAfter.length === 0, 'Override dots should disappear after clearing overrides');
|
||||
});
|
||||
|
||||
await test('Customizer v2: presets apply through standard pipeline', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
|
||||
const btn = await page.$(toggleSel);
|
||||
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
|
||||
await btn.click();
|
||||
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
|
||||
// Click theme tab
|
||||
const themeTab = await page.$('.cust-tab[data-tab="theme"]');
|
||||
if (themeTab) await themeTab.click();
|
||||
await page.waitForTimeout(200);
|
||||
// Click ocean preset
|
||||
const oceanBtn = await page.$('.cust-preset-btn[data-preset="ocean"]');
|
||||
if (!oceanBtn) { console.log(' ⏭️ Ocean preset button not found'); return; }
|
||||
await oceanBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
const result = await page.evaluate(() => {
|
||||
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
const cssAccent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
|
||||
return { hasTheme: !!stored.theme, cssAccent };
|
||||
});
|
||||
assert(result.hasTheme, 'Preset should write theme to localStorage');
|
||||
assert(result.cssAccent.length > 0, 'CSS accent should be set after preset');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: page load applies overrides from localStorage', async () => {
|
||||
// Set overrides BEFORE navigating
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ee1122' } }));
|
||||
});
|
||||
// Reload to trigger init with overrides
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
await page.waitForTimeout(500); // allow pipeline to run
|
||||
const cssAccent = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim()
|
||||
);
|
||||
assert(cssAccent === '#ee1122', `Page load should apply override accent #ee1122 but got "${cssAccent}"`);
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
|
||||
await test('Show Neighbors populates neighborPubkeys from affinity API', async () => {
|
||||
const testPubkey = 'aabbccdd11223344556677889900aabbccddeeff00112233445566778899001122';
|
||||
const neighborPubkey1 = '1111111111111111111111111111111111111111111111111111111111111111';
|
||||
const neighborPubkey2 = '2222222222222222222222222222222222222222222222222222222222222222';
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: testPubkey,
|
||||
neighbors: [
|
||||
{ pubkey: neighborPubkey1, prefix: '11', name: 'Neighbor-1', role: 'repeater', count: 50, score: 0.9, ambiguous: false },
|
||||
{ pubkey: neighborPubkey2, prefix: '22', name: 'Neighbor-2', role: 'companion', count: 20, score: 0.7, ambiguous: false }
|
||||
],
|
||||
total_observations: 70
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const result = await page.evaluate(async (args) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no _mapSelectRefNode' };
|
||||
await window._mapSelectRefNode(args.pk, 'TestNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, { pk: testPubkey });
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.neighbors.includes(neighborPubkey1), 'Should contain neighbor1');
|
||||
assert(result.neighbors.includes(neighborPubkey2), 'Should contain neighbor2');
|
||||
assert(result.neighbors.length === 2, `Expected 2 neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors resolves correct node on hash collision via affinity API', async () => {
|
||||
const nodeA = 'c0dedad4208acb6cbe44b848943fc6d3c5d43cf38a21e48b43826a70862980e4';
|
||||
const nodeB = 'c0f1a2b3000000000000000000000000000000000000000000000000000000ff';
|
||||
const neighborR1 = 'r1aaaaaa000000000000000000000000000000000000000000000000000000aa';
|
||||
const neighborR2 = 'r2bbbbbb000000000000000000000000000000000000000000000000000000bb';
|
||||
const neighborR4 = 'r4dddddd000000000000000000000000000000000000000000000000000000dd';
|
||||
|
||||
await page.route(`**/api/nodes/${nodeA}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeA,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR1, prefix: 'R1', name: 'Repeater-R1', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
|
||||
{ pubkey: neighborR2, prefix: 'R2', name: 'Repeater-R2', role: 'repeater', count: 80, score: 0.85, ambiguous: false }
|
||||
],
|
||||
total_observations: 180
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${nodeB}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeB,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR4, prefix: 'R4', name: 'Repeater-R4', role: 'repeater', count: 60, score: 0.75, ambiguous: false }
|
||||
],
|
||||
total_observations: 60
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Select Node A — should get R1, R2 but NOT R4
|
||||
const resultA = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeA');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeA);
|
||||
assert(resultA.includes(neighborR1), 'Node A should have R1');
|
||||
assert(resultA.includes(neighborR2), 'Node A should have R2');
|
||||
assert(!resultA.includes(neighborR4), 'Node A should NOT have R4');
|
||||
|
||||
// Select Node B — should get R4 but NOT R1, R2
|
||||
const resultB = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeB');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeB);
|
||||
assert(resultB.includes(neighborR4), 'Node B should have R4');
|
||||
assert(!resultB.includes(neighborR1), 'Node B should NOT have R1');
|
||||
assert(!resultB.includes(neighborR2), 'Node B should NOT have R2');
|
||||
|
||||
await page.unroute(`**/api/nodes/${nodeA}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${nodeB}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors falls back to path walking when affinity API returns empty', async () => {
|
||||
const testPubkey = 'fallbacktest0000000000000000000000000000000000000000000000000000';
|
||||
const hopBefore = 'aaaa000000000000000000000000000000000000000000000000000000000000';
|
||||
const hopAfter = 'bbbb000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ node: testPubkey, neighbors: [], total_observations: 0 })
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/paths*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
paths: [{
|
||||
hops: [
|
||||
{ pubkey: hopBefore, name: 'HopBefore' },
|
||||
{ pubkey: testPubkey, name: 'Self' },
|
||||
{ pubkey: hopAfter, name: 'HopAfter' }
|
||||
]
|
||||
}]
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const result = await page.evaluate(async (pk) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no-function' };
|
||||
await window._mapSelectRefNode(pk, 'FallbackNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, testPubkey);
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.neighbors.includes(hopBefore), 'Fallback should find hopBefore');
|
||||
assert(result.neighbors.includes(hopAfter), 'Fallback should find hopAfter');
|
||||
assert(result.neighbors.length === 2, `Expected 2 fallback neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/paths*`);
|
||||
});
|
||||
// ─── Neighbor section tests ───────────────────────────────────────────────
|
||||
|
||||
await test('Node detail: neighbors section exists with correct columns', async () => {
|
||||
// Navigate to a node detail page (use the first node in the list)
|
||||
await page.goto(BASE + '/#/nodes');
|
||||
await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 10000 });
|
||||
// Get the first node's pubkey from the row's data-key attribute
|
||||
const pubkey = await page.$eval('#nodesBody tr[data-key]', el => el.dataset.key);
|
||||
await page.goto(BASE + '/#/nodes/' + pubkey);
|
||||
await page.waitForSelector('#node-neighbors', { timeout: 10000 });
|
||||
// Check the section exists
|
||||
const header = await page.$eval('#fullNeighborsHeader', el => el.textContent);
|
||||
assert(header.startsWith('Neighbors'), 'Header should start with "Neighbors", got: ' + header);
|
||||
// Wait for content to load (either table or empty state)
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.getElementById('fullNeighborsContent');
|
||||
return el && !el.innerHTML.includes('spinner');
|
||||
}, { timeout: 10000 });
|
||||
const hasTable = await page.$('#fullNeighborsContent .data-table');
|
||||
if (hasTable) {
|
||||
// Check columns
|
||||
const headers = await page.$$eval('#fullNeighborsContent thead th', ths => ths.map(t => t.textContent));
|
||||
assert(headers.includes('Neighbor'), 'Should have Neighbor column');
|
||||
assert(headers.includes('Role'), 'Should have Role column');
|
||||
assert(headers.includes('Score'), 'Should have Score column');
|
||||
assert(headers.includes('Obs'), 'Should have Obs column');
|
||||
assert(headers.includes('Last Seen'), 'Should have Last Seen column');
|
||||
assert(headers.includes('Conf'), 'Should have Conf column');
|
||||
} else {
|
||||
// Empty state
|
||||
const text = await page.$eval('#fullNeighborsContent', el => el.textContent);
|
||||
assert(text.includes('No neighbor data') || text.includes('Could not load'), 'Should show empty or error state');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ─── End neighbor section tests ───────────────────────────────────────────
|
||||
|
||||
// ─── Affinity debug overlay tests ─────────────────────────────────────────
|
||||
|
||||
await test('Map: affinity debug checkbox exists in DOM', async () => {
|
||||
await page.goto(BASE + '/#/map');
|
||||
await page.waitForSelector('#mapControls', { timeout: 5000 });
|
||||
const checkbox = await page.$('#mcAffinityDebug');
|
||||
assert(checkbox !== null, 'Affinity debug checkbox should exist in DOM');
|
||||
});
|
||||
|
||||
await test('Map: affinity debug checkbox toggles without crash', async () => {
|
||||
await page.goto(BASE + '/#/map');
|
||||
await page.waitForSelector('#mapControls', { timeout: 5000 });
|
||||
// Make the checkbox visible by setting localStorage
|
||||
await page.evaluate(() => localStorage.setItem('meshcore-affinity-debug', 'true'));
|
||||
await page.reload();
|
||||
await page.waitForSelector('#mapControls', { timeout: 5000 });
|
||||
const label = await page.$('#mcAffinityDebugLabel');
|
||||
if (label) {
|
||||
const display = await label.evaluate(el => getComputedStyle(el).display);
|
||||
// When debugAffinity or localStorage is set, label should be visible
|
||||
// Just verify toggling doesn't crash
|
||||
const cb = await page.$('#mcAffinityDebug');
|
||||
if (cb) {
|
||||
await cb.click();
|
||||
// Wait a bit for fetch to complete (or fail gracefully)
|
||||
await page.waitForTimeout(500);
|
||||
await cb.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
// Clean up
|
||||
await page.evaluate(() => localStorage.removeItem('meshcore-affinity-debug'));
|
||||
assert(true, 'Toggle did not crash');
|
||||
});
|
||||
|
||||
await test('Node detail: affinity debug section expandable', async () => {
|
||||
await page.goto(BASE + '/#/nodes');
|
||||
await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 10000 });
|
||||
// Enable debug mode
|
||||
await page.evaluate(() => localStorage.setItem('meshcore-affinity-debug', 'true'));
|
||||
// Click first node to go to detail
|
||||
const nodeLink = await page.$('a[href*="/nodes/"]');
|
||||
if (nodeLink) {
|
||||
await nodeLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
const debugPanel = await page.$('#node-affinity-debug');
|
||||
if (debugPanel) {
|
||||
const display = await debugPanel.evaluate(el => el.style.display);
|
||||
// Panel should be visible when debug is enabled
|
||||
const header = await debugPanel.$('h4');
|
||||
if (header) {
|
||||
// Click to expand
|
||||
await header.click();
|
||||
await page.waitForTimeout(300);
|
||||
const body = await debugPanel.$('.affinity-debug-body');
|
||||
if (body) {
|
||||
const bodyDisplay = await body.evaluate(el => el.style.display);
|
||||
assert(bodyDisplay !== 'none', 'Debug body should be expanded after click');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => localStorage.removeItem('meshcore-affinity-debug'));
|
||||
assert(true, 'Debug panel expansion works');
|
||||
});
|
||||
|
||||
// ─── 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
|
||||
try {
|
||||
const coverage = await page.evaluate(() => window.__coverage__);
|
||||
|
||||
+238
-1185
File diff suppressed because it is too large
Load Diff
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
+1
-51
@@ -75,54 +75,4 @@ test('no setInterval remains in animation hot path', () => {
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
|
||||
/* === Null-guard coverage for rAF callbacks === */
|
||||
const src2 = fs.readFileSync('public/live.js', 'utf8');
|
||||
let p2 = 0, f2 = 0;
|
||||
function test2(name, fn) {
|
||||
try { fn(); p2++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { f2++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
console.log('\n=== Null guards on rAF animation callbacks ===');
|
||||
|
||||
test2('animatePath tick() has null guard', () => {
|
||||
// tick is inside animatePath, after "function tick(now)"
|
||||
const tickStart = src2.indexOf('function tick(now)');
|
||||
const tickBody = src2.substring(tickStart, tickStart + 200);
|
||||
assert.ok(tickBody.includes('!animLayer || !pathsLayer'), 'tick() missing animLayer/pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('animatePath fadeOut() has null guard', () => {
|
||||
const fadeOutStart = src2.indexOf('function fadeOut(now)');
|
||||
const fadeOutBody = src2.substring(fadeOutStart, fadeOutStart + 200);
|
||||
assert.ok(fadeOutBody.includes('!animLayer || !pathsLayer'), 'fadeOut() missing animLayer/pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('drawAnimatedLine animateLine() has null guard', () => {
|
||||
const lineStart = src2.indexOf('function animateLine(now)');
|
||||
const lineBody = src2.substring(lineStart, lineStart + 200);
|
||||
assert.ok(lineBody.includes('!animLayer || !pathsLayer'), 'animateLine() missing animLayer/pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('drawAnimatedLine animateFade() has null guard', () => {
|
||||
const fadeStart = src2.indexOf('function animateFade(now)');
|
||||
const fadeBody = src2.substring(fadeStart, fadeStart + 200);
|
||||
assert.ok(fadeBody.includes('!pathsLayer'), 'animateFade() missing pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('pulseNode animatePulse() has null guard', () => {
|
||||
const pulseStart = src2.indexOf('function animatePulse(now)');
|
||||
const pulseBody = src2.substring(pulseStart, pulseStart + 200);
|
||||
assert.ok(pulseBody.includes('!animLayer'), 'animatePulse() missing animLayer null guard');
|
||||
});
|
||||
|
||||
test2('ghostPulse has null guard', () => {
|
||||
const ghostStart = src2.indexOf('function ghostPulse(now)');
|
||||
const ghostBody = src2.substring(ghostStart, ghostStart + 200);
|
||||
assert.ok(ghostBody.includes('!animLayer'), 'ghostPulse() missing animLayer null guard');
|
||||
});
|
||||
|
||||
console.log(`\n${p2} passed, ${f2} failed\n`);
|
||||
if (f2 > 0) process.exit(1);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
-906
@@ -1,906 +0,0 @@
|
||||
/* Unit tests for live.js functions (tested via VM sandbox)
|
||||
* Part of #344 — live.js coverage
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
const pendingTests = [];
|
||||
function test(name, fn) {
|
||||
try {
|
||||
const out = fn();
|
||||
if (out && typeof out.then === 'function') {
|
||||
pendingTests.push(
|
||||
out.then(() => { passed++; console.log(` ✅ ${name}`); })
|
||||
.catch((e) => { failed++; console.log(` ❌ ${name}: ${e.message}`); })
|
||||
);
|
||||
return;
|
||||
}
|
||||
passed++; console.log(` ✅ ${name}`);
|
||||
} catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
// --- Browser-like sandbox ---
|
||||
function makeSandbox() {
|
||||
const ctx = {
|
||||
window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: (tag) => ({
|
||||
tagName: tag, id: '', textContent: '', innerHTML: '', style: {},
|
||||
classList: { add() {}, remove() {}, contains() { return false; } },
|
||||
setAttribute() {}, getAttribute() { return null; },
|
||||
addEventListener() {}, focus() {},
|
||||
getContext: () => ({
|
||||
clearRect() {}, fillRect() {}, beginPath() {}, arc() {}, fill() {},
|
||||
scale() {}, fillStyle: '', font: '', fillText() {},
|
||||
}),
|
||||
offsetWidth: 200, offsetHeight: 40, width: 0, height: 0,
|
||||
}),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
createElementNS: () => ({
|
||||
tagName: 'svg', id: '', textContent: '', innerHTML: '', style: {},
|
||||
setAttribute() {}, getAttribute() { return null; },
|
||||
}),
|
||||
documentElement: { getAttribute: () => null, setAttribute: () => {} },
|
||||
body: { appendChild: () => {}, removeChild: () => {}, contains: () => false },
|
||||
hidden: false,
|
||||
},
|
||||
console,
|
||||
Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
|
||||
Error, TypeError, Map, Set, Promise, URLSearchParams,
|
||||
parseInt, parseFloat, isNaN, isFinite,
|
||||
encodeURIComponent, decodeURIComponent,
|
||||
setTimeout: () => 0, clearTimeout: () => {},
|
||||
setInterval: () => 0, clearInterval: () => {},
|
||||
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
||||
performance: { now: () => Date.now() },
|
||||
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
||||
cancelAnimationFrame: () => {},
|
||||
localStorage: (() => {
|
||||
const store = {};
|
||||
return {
|
||||
getItem: k => store[k] !== undefined ? store[k] : null,
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: k => { delete store[k]; },
|
||||
};
|
||||
})(),
|
||||
location: { hash: '', protocol: 'https:', host: 'localhost' },
|
||||
CustomEvent: class CustomEvent {},
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
|
||||
navigator: {},
|
||||
visualViewport: null,
|
||||
MutationObserver: function() { this.observe = () => {}; this.disconnect = () => {}; },
|
||||
WebSocket: function() { this.close = () => {}; },
|
||||
IATA_COORDS_GEO: {},
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadInCtx(ctx, file) {
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
|
||||
function makeLeafletMock() {
|
||||
return {
|
||||
circleMarker: () => {
|
||||
const m = {
|
||||
addTo() { return m; }, bindTooltip() { return m; }, on() { return m; },
|
||||
setRadius() {}, setStyle() {}, setLatLng() {},
|
||||
getLatLng() { return { lat: 0, lng: 0 }; },
|
||||
_baseColor: '', _baseSize: 5, _glowMarker: null, remove() {},
|
||||
};
|
||||
return m;
|
||||
},
|
||||
polyline: () => { const p = { addTo() { return p; }, setStyle() {}, remove() {} }; return p; },
|
||||
polygon: () => { const p = { addTo() { return p; }, remove() {} }; return p; },
|
||||
map: () => {
|
||||
const m = {
|
||||
setView() { return m; }, addLayer() { return m; }, on() { return m; },
|
||||
getZoom() { return 11; }, getCenter() { return { lat: 37, lng: -122 }; },
|
||||
getBounds() { return { contains: () => true }; }, fitBounds() { return m; },
|
||||
invalidateSize() {}, remove() {}, hasLayer() { return false; }, removeLayer() {},
|
||||
};
|
||||
return m;
|
||||
},
|
||||
layerGroup: () => {
|
||||
const g = {
|
||||
addTo() { return g; }, addLayer() {}, removeLayer() {},
|
||||
clearLayers() {}, hasLayer() { return true; }, eachLayer() {},
|
||||
};
|
||||
return g;
|
||||
},
|
||||
tileLayer: () => ({ addTo() { return this; } }),
|
||||
control: { attribution: () => ({ addTo() {} }) },
|
||||
DomUtil: { addClass() {}, removeClass() {} },
|
||||
};
|
||||
}
|
||||
|
||||
function addLiveGlobals(ctx) {
|
||||
ctx.L = makeLeafletMock();
|
||||
ctx.registerPage = () => {};
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.connectWS = () => {};
|
||||
ctx.api = () => Promise.resolve([]);
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.favStar = () => '';
|
||||
ctx.bindFavStars = () => {};
|
||||
ctx.getFavorites = () => [];
|
||||
ctx.isFavorite = () => false;
|
||||
ctx.HopResolver = { init() {}, resolve: () => ({}), ready: () => false };
|
||||
ctx.MeshAudio = null;
|
||||
ctx.RegionFilter = { init() {}, getSelected: () => null, onRegionChange: () => {} };
|
||||
}
|
||||
|
||||
function makeLiveSandbox({ withAppJs = false } = {}) {
|
||||
const ctx = makeSandbox();
|
||||
addLiveGlobals(ctx);
|
||||
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
if (withAppJs) loadInCtx(ctx, 'public/app.js');
|
||||
try { loadInCtx(ctx, 'public/live.js'); } catch (e) {
|
||||
console.error('live.js load error:', e.message);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ===== dbPacketToLive =====
|
||||
console.log('\n=== live.js: dbPacketToLive ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const dbPacketToLive = ctx.window._liveDbPacketToLive;
|
||||
assert.ok(dbPacketToLive, '_liveDbPacketToLive must be exposed');
|
||||
|
||||
test('converts basic DB packet to live format', () => {
|
||||
const pkt = {
|
||||
id: 42, hash: 'abc123',
|
||||
raw_hex: 'deadbeef',
|
||||
path_json: '["hop1","hop2"]',
|
||||
decoded_json: '{"type":"GRP_TXT","text":"hello"}',
|
||||
timestamp: '2024-06-15T12:00:00Z',
|
||||
snr: 7.5, rssi: -85, observer_name: 'ObsA',
|
||||
};
|
||||
const result = dbPacketToLive(pkt);
|
||||
assert.strictEqual(result.id, 42);
|
||||
assert.strictEqual(result.hash, 'abc123');
|
||||
assert.strictEqual(result.raw, 'deadbeef');
|
||||
assert.strictEqual(result.snr, 7.5);
|
||||
assert.strictEqual(result.rssi, -85);
|
||||
assert.strictEqual(result.observer, 'ObsA');
|
||||
assert.strictEqual(result.decoded.header.payloadTypeName, 'GRP_TXT');
|
||||
assert.strictEqual(result.decoded.payload.text, 'hello');
|
||||
assert.deepStrictEqual(result.decoded.path.hops, ['hop1', 'hop2']);
|
||||
assert.strictEqual(result._ts, new Date('2024-06-15T12:00:00Z').getTime());
|
||||
});
|
||||
|
||||
test('handles null decoded_json', () => {
|
||||
const pkt = { id: 1, hash: 'x', decoded_json: null, path_json: null, timestamp: '2024-01-01T00:00:00Z' };
|
||||
const result = dbPacketToLive(pkt);
|
||||
assert.strictEqual(result.decoded.header.payloadTypeName, 'UNKNOWN');
|
||||
assert.deepStrictEqual(result.decoded.path.hops, []);
|
||||
});
|
||||
|
||||
test('uses payload_type_name as fallback', () => {
|
||||
const pkt = { id: 2, hash: 'y', decoded_json: '{}', path_json: '[]', timestamp: '2024-01-01T00:00:00Z', payload_type_name: 'ADVERT' };
|
||||
const result = dbPacketToLive(pkt);
|
||||
assert.strictEqual(result.decoded.header.payloadTypeName, 'ADVERT');
|
||||
});
|
||||
|
||||
test('uses created_at as timestamp fallback', () => {
|
||||
const pkt = { id: 3, hash: 'z', decoded_json: '{}', path_json: '[]', created_at: '2024-03-01T06:00:00Z' };
|
||||
const result = dbPacketToLive(pkt);
|
||||
assert.strictEqual(result._ts, new Date('2024-03-01T06:00:00Z').getTime());
|
||||
});
|
||||
}
|
||||
|
||||
// ===== expandToBufferEntries =====
|
||||
console.log('\n=== live.js: expandToBufferEntries ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const expand = ctx.window._liveExpandToBufferEntries;
|
||||
assert.ok(expand, '_liveExpandToBufferEntries must be exposed');
|
||||
|
||||
test('single packet without observations returns one entry', () => {
|
||||
const pkts = [{
|
||||
id: 1, hash: 'h1', timestamp: '2024-06-15T12:00:00Z',
|
||||
decoded_json: '{"type":"GRP_TXT"}', path_json: '[]',
|
||||
}];
|
||||
const entries = expand(pkts);
|
||||
assert.strictEqual(entries.length, 1);
|
||||
assert.strictEqual(entries[0].pkt.id, 1);
|
||||
assert.strictEqual(entries[0].ts, new Date('2024-06-15T12:00:00Z').getTime());
|
||||
});
|
||||
|
||||
test('packet with observations expands to one entry per observation', () => {
|
||||
const pkts = [{
|
||||
id: 10, hash: 'h10', timestamp: '2024-06-15T12:00:00Z',
|
||||
decoded_json: '{"type":"ADVERT"}', path_json: '[]', raw_hex: 'ff',
|
||||
observations: [
|
||||
{ timestamp: '2024-06-15T12:00:01Z', snr: 5, observer_name: 'O1' },
|
||||
{ timestamp: '2024-06-15T12:00:02Z', snr: 8, observer_name: 'O2' },
|
||||
{ timestamp: '2024-06-15T12:00:03Z', snr: 3, observer_name: 'O3' },
|
||||
],
|
||||
}];
|
||||
const entries = expand(pkts);
|
||||
assert.strictEqual(entries.length, 3);
|
||||
assert.strictEqual(entries[0].pkt.observer, 'O1');
|
||||
assert.strictEqual(entries[1].pkt.observer, 'O2');
|
||||
assert.strictEqual(entries[2].pkt.observer, 'O3');
|
||||
// All should share the same hash
|
||||
assert.strictEqual(entries[0].pkt.hash, 'h10');
|
||||
assert.strictEqual(entries[2].pkt.hash, 'h10');
|
||||
// Entries should be in chronological order
|
||||
assert.ok(entries[0].ts < entries[1].ts, 'entry 0 should be before entry 1');
|
||||
assert.ok(entries[1].ts < entries[2].ts, 'entry 1 should be before entry 2');
|
||||
});
|
||||
|
||||
test('empty observations array treated as no observations', () => {
|
||||
const pkts = [{
|
||||
id: 5, hash: 'h5', timestamp: '2024-01-01T00:00:00Z',
|
||||
decoded_json: '{}', path_json: '[]', observations: [],
|
||||
}];
|
||||
const entries = expand(pkts);
|
||||
assert.strictEqual(entries.length, 1);
|
||||
});
|
||||
|
||||
test('multiple packets expand independently', () => {
|
||||
const pkts = [
|
||||
{ id: 1, hash: 'h1', timestamp: '2024-01-01T00:00:00Z', decoded_json: '{}', path_json: '[]' },
|
||||
{
|
||||
id: 2, hash: 'h2', timestamp: '2024-01-01T00:00:00Z', decoded_json: '{}', path_json: '[]', raw_hex: 'aa',
|
||||
observations: [
|
||||
{ timestamp: '2024-01-01T00:00:01Z', observer_name: 'X' },
|
||||
{ timestamp: '2024-01-01T00:00:02Z', observer_name: 'Y' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const entries = expand(pkts);
|
||||
assert.strictEqual(entries.length, 3);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 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) =====
|
||||
console.log('\n=== live.js: SEG_MAP ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const SEG_MAP = ctx.window._liveSEG_MAP;
|
||||
assert.ok(SEG_MAP, '_liveSEG_MAP must be exposed');
|
||||
|
||||
test('all digits 0-9 are mapped', () => {
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
assert.ok(SEG_MAP[String(i)] !== undefined, `digit ${i} must be in SEG_MAP`);
|
||||
assert.ok(SEG_MAP[String(i)] > 0, `digit ${i} must have non-zero segments`);
|
||||
}
|
||||
});
|
||||
|
||||
test('digit 8 lights all 7 segments and no others', () => {
|
||||
// 0x7F = 0b01111111 — all 7 segment bits on, MSB (colon) off
|
||||
const val = SEG_MAP['8'];
|
||||
assert.strictEqual(val & 0x7F, 0x7F, 'all 7 segment bits should be set');
|
||||
assert.strictEqual(val & 0x80, 0, 'colon bit should not be set for a digit');
|
||||
});
|
||||
|
||||
test('colon only sets the MSB (dot/colon indicator)', () => {
|
||||
const val = SEG_MAP[':'];
|
||||
assert.strictEqual(val & 0x80, 0x80, 'MSB (colon bit) should be set');
|
||||
assert.strictEqual(val & 0x7F, 0, 'no segment bits should be set for colon');
|
||||
});
|
||||
|
||||
test('space lights no segments', () => {
|
||||
assert.strictEqual(SEG_MAP[' '], 0x00, 'space should have no bits set');
|
||||
});
|
||||
|
||||
test('digit 1 lights fewer segments than digit 8', () => {
|
||||
// Behavioral: 1 has fewer segments lit than 8
|
||||
const ones = (n) => { let c = 0; while (n) { c += n & 1; n >>= 1; } return c; };
|
||||
assert.ok(ones(SEG_MAP['1']) < ones(SEG_MAP['8']),
|
||||
'digit 1 should have fewer segment bits than digit 8');
|
||||
});
|
||||
|
||||
test('VCR mode letters are mapped with non-zero segments', () => {
|
||||
for (const ch of ['P', 'A', 'U', 'S', 'E', 'L', 'I', 'V']) {
|
||||
assert.ok(SEG_MAP[ch] !== undefined, `${ch} must be in SEG_MAP`);
|
||||
assert.ok(SEG_MAP[ch] > 0, `${ch} must have non-zero segments`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== VCR state machine =====
|
||||
console.log('\n=== live.js: VCR state machine ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const VCR = ctx.window._liveVCR;
|
||||
const vcrSetMode = ctx.window._liveVcrSetMode;
|
||||
const vcrPause = ctx.window._liveVcrPause;
|
||||
const vcrSpeedCycle = ctx.window._liveVcrSpeedCycle;
|
||||
assert.ok(VCR, '_liveVCR must be exposed');
|
||||
|
||||
test('VCR initial mode is LIVE', () => {
|
||||
assert.strictEqual(VCR().mode, 'LIVE');
|
||||
});
|
||||
|
||||
test('vcrSetMode changes mode', () => {
|
||||
vcrSetMode('PAUSED');
|
||||
assert.strictEqual(VCR().mode, 'PAUSED');
|
||||
assert.ok(VCR().frozenNow != null, 'frozenNow should be set when not LIVE');
|
||||
});
|
||||
|
||||
test('vcrSetMode LIVE clears frozenNow', () => {
|
||||
vcrSetMode('LIVE');
|
||||
assert.strictEqual(VCR().mode, 'LIVE');
|
||||
assert.strictEqual(VCR().frozenNow, null);
|
||||
});
|
||||
|
||||
test('vcrPause stops replay and sets PAUSED', () => {
|
||||
vcrSetMode('LIVE');
|
||||
vcrPause();
|
||||
assert.strictEqual(VCR().mode, 'PAUSED');
|
||||
assert.strictEqual(VCR().missedCount, 0);
|
||||
});
|
||||
|
||||
test('vcrPause is idempotent', () => {
|
||||
vcrPause();
|
||||
const frozen1 = VCR().frozenNow;
|
||||
assert.strictEqual(VCR().mode, 'PAUSED', 'mode should be PAUSED after first call');
|
||||
vcrPause();
|
||||
assert.strictEqual(VCR().frozenNow, frozen1);
|
||||
assert.strictEqual(VCR().mode, 'PAUSED', 'mode should stay PAUSED after second call');
|
||||
});
|
||||
|
||||
test('vcrSpeedCycle cycles through 1,2,4,8', () => {
|
||||
vcrSetMode('LIVE');
|
||||
VCR().speed = 1;
|
||||
vcrSpeedCycle();
|
||||
assert.strictEqual(VCR().speed, 2);
|
||||
vcrSpeedCycle();
|
||||
assert.strictEqual(VCR().speed, 4);
|
||||
vcrSpeedCycle();
|
||||
assert.strictEqual(VCR().speed, 8);
|
||||
vcrSpeedCycle();
|
||||
assert.strictEqual(VCR().speed, 1); // wraps around
|
||||
});
|
||||
|
||||
const vcrResumeLive = ctx.window._liveVcrResumeLive;
|
||||
assert.ok(vcrResumeLive, '_liveVcrResumeLive must be exposed');
|
||||
|
||||
test('vcrResumeLive transitions from PAUSED to LIVE', () => {
|
||||
vcrPause();
|
||||
assert.strictEqual(VCR().mode, 'PAUSED');
|
||||
assert.ok(VCR().frozenNow != null, 'frozenNow should be set when paused');
|
||||
vcrResumeLive();
|
||||
assert.strictEqual(VCR().mode, 'LIVE');
|
||||
assert.strictEqual(VCR().frozenNow, null, 'frozenNow should be cleared');
|
||||
assert.strictEqual(VCR().playhead, -1, 'playhead should reset to -1');
|
||||
assert.strictEqual(VCR().speed, 1, 'speed should reset to 1');
|
||||
assert.strictEqual(VCR().missedCount, 0, 'missedCount should be 0');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== getFavoritePubkeys =====
|
||||
console.log('\n=== live.js: getFavoritePubkeys ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const getFavPubkeys = ctx.window._liveGetFavoritePubkeys;
|
||||
assert.ok(getFavPubkeys, '_liveGetFavoritePubkeys must be exposed');
|
||||
|
||||
test('returns empty array when no favorites stored', () => {
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(Array.isArray(result));
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
|
||||
test('reads from meshcore-favorites', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["pk1","pk2"]');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(result.includes('pk1'));
|
||||
assert.ok(result.includes('pk2'));
|
||||
});
|
||||
|
||||
test('reads from meshcore-my-nodes pubkeys', () => {
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', '[{"pubkey":"mynode1"},{"pubkey":"mynode2"}]');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(result.includes('mynode1'));
|
||||
assert.ok(result.includes('mynode2'));
|
||||
});
|
||||
|
||||
test('merges both sources', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["fav1"]');
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', '[{"pubkey":"mine1"}]');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(result.includes('fav1'));
|
||||
assert.ok(result.includes('mine1'));
|
||||
assert.strictEqual(result.length, 2);
|
||||
});
|
||||
|
||||
test('handles corrupt localStorage gracefully', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', 'not json');
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', '{bad}');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(Array.isArray(result));
|
||||
assert.strictEqual(result.length, 0, 'corrupt data should yield empty array');
|
||||
});
|
||||
|
||||
test('filters out falsy values', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["pk1",null,"",false,"pk2"]');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(!result.includes(null));
|
||||
assert.ok(!result.includes(''));
|
||||
assert.strictEqual(result.length, 2);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== packetInvolvesFavorite =====
|
||||
console.log('\n=== live.js: packetInvolvesFavorite ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
// Clean localStorage to avoid leakage from prior test sections
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const involves = ctx.window._livePacketInvolvesFavorite;
|
||||
assert.ok(involves, '_livePacketInvolvesFavorite must be exposed');
|
||||
|
||||
test('returns false when no favorites', () => {
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const pkt = { decoded: { header: {}, payload: { pubKey: 'abc' } } };
|
||||
assert.strictEqual(involves(pkt), false);
|
||||
});
|
||||
|
||||
test('matches sender pubKey', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["sender123"]');
|
||||
const pkt = { decoded: { header: {}, payload: { pubKey: 'sender123' } } };
|
||||
assert.strictEqual(involves(pkt), true);
|
||||
});
|
||||
|
||||
test('matches hop prefix', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["abcdef1234567890"]');
|
||||
const pkt = { decoded: { header: {}, payload: {}, path: { hops: ['abcd'] } } };
|
||||
assert.strictEqual(involves(pkt), true);
|
||||
});
|
||||
|
||||
test('does not match unrelated hop', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["abcdef1234567890"]');
|
||||
const pkt = { decoded: { header: {}, payload: {}, path: { hops: ['ffff'] } } };
|
||||
assert.strictEqual(involves(pkt), false);
|
||||
});
|
||||
|
||||
test('handles missing decoded fields gracefully', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["xyz"]');
|
||||
const pkt = {};
|
||||
assert.strictEqual(involves(pkt), false);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== isNodeFavorited =====
|
||||
console.log('\n=== live.js: isNodeFavorited ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
// Clean localStorage to avoid leakage from prior test sections
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const isFav = ctx.window._liveIsNodeFavorited;
|
||||
assert.ok(isFav, '_liveIsNodeFavorited must be exposed');
|
||||
|
||||
test('returns true when pubkey is in favorites', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["pk1","pk2"]');
|
||||
assert.strictEqual(isFav('pk1'), true);
|
||||
});
|
||||
|
||||
test('returns false when pubkey not in favorites', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["pk1"]');
|
||||
assert.strictEqual(isFav('pk99'), false);
|
||||
});
|
||||
|
||||
test('returns false with empty favorites', () => {
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
assert.strictEqual(isFav('pk1'), false);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== formatLiveTimestampHtml =====
|
||||
console.log('\n=== live.js: formatLiveTimestampHtml ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox({ withAppJs: true });
|
||||
|
||||
const fmt = ctx.window._liveFormatLiveTimestampHtml;
|
||||
assert.ok(fmt, '_liveFormatLiveTimestampHtml must be exposed');
|
||||
|
||||
test('formats a recent ISO timestamp', () => {
|
||||
const iso = new Date(Date.now() - 30000).toISOString();
|
||||
const html = fmt(iso);
|
||||
assert.ok(html.includes('timestamp-text'), 'should contain timestamp-text span');
|
||||
assert.ok(html.includes('title='), 'should have tooltip');
|
||||
});
|
||||
|
||||
test('handles null input', () => {
|
||||
const html = fmt(null);
|
||||
assert.ok(typeof html === 'string');
|
||||
assert.ok(html.includes('—'), 'null input should render em-dash fallback');
|
||||
});
|
||||
|
||||
test('handles numeric timestamp', () => {
|
||||
const html = fmt(Date.now() - 60000);
|
||||
assert.ok(typeof html === 'string');
|
||||
assert.ok(html.includes('timestamp-text'), 'numeric timestamp should produce timestamp-text span');
|
||||
assert.ok(html.includes('title='), 'numeric timestamp should have tooltip');
|
||||
});
|
||||
|
||||
test('future timestamp shows warning icon', () => {
|
||||
const future = new Date(Date.now() + 120000).toISOString();
|
||||
const html = fmt(future);
|
||||
assert.ok(html.includes('timestamp-future-icon'), 'should show future warning');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== resolveHopPositions =====
|
||||
console.log('\n=== live.js: resolveHopPositions ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const resolve = ctx.window._liveResolveHopPositions;
|
||||
const nodeData = ctx.window._liveNodeData();
|
||||
const nodeMarkers = ctx.window._liveNodeMarkers();
|
||||
assert.ok(resolve, '_liveResolveHopPositions must be exposed');
|
||||
|
||||
test('returns empty array for empty hops', () => {
|
||||
const result = resolve([], {});
|
||||
assert.deepStrictEqual(result, []);
|
||||
});
|
||||
|
||||
test('returns sender position when payload has pubKey + coords', () => {
|
||||
const payload = { pubKey: 'sender1', name: 'Sender', lat: 37.5, lon: -122.0 };
|
||||
// No nodes in nodeData, so hops won't resolve
|
||||
const result = resolve([], payload);
|
||||
// With empty hops, the function still adds the sender as an anchor point.
|
||||
assert.ok(Array.isArray(result), 'should return an array');
|
||||
assert.strictEqual(result.length, 1, 'sender coords should produce one anchor position');
|
||||
assert.strictEqual(result[0].pos[0], 37.5, 'anchor should use sender lat');
|
||||
assert.strictEqual(result[0].pos[1], -122.0, 'anchor should use sender lon');
|
||||
assert.strictEqual(result[0].name, 'Sender', 'anchor should use sender name');
|
||||
assert.strictEqual(result[0].known, true, 'sender with coords should be marked as known');
|
||||
});
|
||||
|
||||
test('resolves known node from nodeData', () => {
|
||||
// Add a node to nodeData
|
||||
nodeData['nodeA_pubkey'] = { public_key: 'nodeA_pubkey', name: 'NodeA', lat: 37.3, lon: -122.0 };
|
||||
nodeData['nodeB_pubkey'] = { public_key: 'nodeB_pubkey', name: 'NodeB', lat: 38.0, lon: -121.0 };
|
||||
// Need HopResolver to resolve the hop prefix — set on both ctx and window
|
||||
const mockResolver = {
|
||||
init() {},
|
||||
ready() { return true; },
|
||||
resolve(hops) {
|
||||
const map = {};
|
||||
for (const h of hops) {
|
||||
if (h === 'nodeA') map[h] = { name: 'NodeA', pubkey: 'nodeA_pubkey' };
|
||||
else if (h === 'nodeB') map[h] = { name: 'NodeB', pubkey: 'nodeB_pubkey' };
|
||||
else map[h] = { name: null, pubkey: null };
|
||||
}
|
||||
return map;
|
||||
},
|
||||
};
|
||||
ctx.HopResolver = mockResolver;
|
||||
ctx.window.HopResolver = mockResolver;
|
||||
// Need at least 2 known nodes for ghost mode to not filter down
|
||||
const result = resolve(['nodeA', 'nodeB'], {});
|
||||
assert.ok(result.length >= 2, `expected >= 2 positions, got ${result.length}`);
|
||||
const foundA = result.find(r => r.key === 'nodeA_pubkey');
|
||||
assert.ok(foundA, 'should resolve nodeA to nodeA_pubkey');
|
||||
assert.strictEqual(foundA.pos[0], 37.3);
|
||||
assert.strictEqual(foundA.pos[1], -122.0);
|
||||
assert.strictEqual(foundA.known, true);
|
||||
delete nodeData['nodeA_pubkey'];
|
||||
delete nodeData['nodeB_pubkey'];
|
||||
});
|
||||
|
||||
test('ghost hops get interpolated positions between known nodes', () => {
|
||||
// Set up: two known nodes, one unknown hop between them
|
||||
nodeData['n1'] = { public_key: 'n1', name: 'N1', lat: 37.0, lon: -122.0 };
|
||||
nodeData['n2'] = { public_key: 'n2', name: 'N2', lat: 38.0, lon: -121.0 };
|
||||
const mockResolver = {
|
||||
init() {},
|
||||
ready() { return true; },
|
||||
resolve(hops) {
|
||||
const map = {};
|
||||
for (const h of hops) {
|
||||
if (h === 'h1') map[h] = { name: 'N1', pubkey: 'n1' };
|
||||
else if (h === 'h3') map[h] = { name: 'N2', pubkey: 'n2' };
|
||||
else map[h] = { name: null, pubkey: null };
|
||||
}
|
||||
return map;
|
||||
},
|
||||
};
|
||||
ctx.HopResolver = mockResolver;
|
||||
ctx.window.HopResolver = mockResolver;
|
||||
const result = resolve(['h1', 'h2', 'h3'], {});
|
||||
assert.ok(result.length >= 2, `should have at least 2 positions, got ${result.length}`);
|
||||
// Check that the ghost hop got an interpolated position
|
||||
const ghost = result.find(r => r.ghost);
|
||||
assert.ok(ghost, 'ghost hop should be present in resolved positions — if missing, interpolation logic changed');
|
||||
assert.ok(ghost.pos[0] > 37.0 && ghost.pos[0] < 38.0, 'ghost lat should be interpolated');
|
||||
assert.ok(ghost.pos[1] > -122.0 && ghost.pos[1] < -121.0, 'ghost lon should be interpolated');
|
||||
delete nodeData['n1'];
|
||||
delete nodeData['n2'];
|
||||
});
|
||||
}
|
||||
|
||||
// ===== bufferPacket and VCR buffer management =====
|
||||
console.log('\n=== live.js: bufferPacket / VCR buffer ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const bufferPacket = ctx.window._liveBufferPacket;
|
||||
const VCR = ctx.window._liveVCR;
|
||||
assert.ok(bufferPacket, '_liveBufferPacket must be exposed');
|
||||
|
||||
test('bufferPacket adds entry to VCR buffer', () => {
|
||||
const initialLen = VCR().buffer.length;
|
||||
const pkt = { hash: 'test1', decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: {} } };
|
||||
bufferPacket(pkt);
|
||||
assert.strictEqual(VCR().buffer.length, initialLen + 1);
|
||||
const last = VCR().buffer[VCR().buffer.length - 1];
|
||||
assert.strictEqual(last.pkt.hash, 'test1');
|
||||
assert.ok(last.ts > 0);
|
||||
});
|
||||
|
||||
test('bufferPacket sets _ts on packet', () => {
|
||||
const pkt = { hash: 'test2', decoded: { header: {}, payload: {} } };
|
||||
const before = Date.now();
|
||||
bufferPacket(pkt);
|
||||
const after = Date.now();
|
||||
assert.ok(pkt._ts >= before && pkt._ts <= after, `_ts should be between ${before} and ${after}, got ${pkt._ts}`);
|
||||
});
|
||||
|
||||
test('VCR buffer caps at ~2000 entries', () => {
|
||||
// Fill buffer past 2000
|
||||
VCR().buffer.length = 0;
|
||||
for (let i = 0; i < 2100; i++) {
|
||||
VCR().buffer.push({ ts: Date.now(), pkt: { hash: 'fill' + i } });
|
||||
}
|
||||
// Next bufferPacket triggers trim: 2100+1=2101 > 2000 → splice(0, 500) → 1601
|
||||
const pkt = { hash: 'overflow', decoded: { header: {}, payload: {} } };
|
||||
bufferPacket(pkt);
|
||||
assert.strictEqual(VCR().buffer.length, 1601, `buffer should be 2101 - 500 = 1601, got ${VCR().buffer.length}`);
|
||||
});
|
||||
|
||||
test('bufferPacket increments missedCount when PAUSED', () => {
|
||||
ctx.window._liveVcrSetMode('PAUSED');
|
||||
VCR().missedCount = 0;
|
||||
const pkt = { hash: 'missed1', decoded: { header: {}, payload: {} } };
|
||||
bufferPacket(pkt);
|
||||
assert.strictEqual(VCR().missedCount, 1);
|
||||
bufferPacket({ hash: 'missed2', decoded: { header: {}, payload: {} } });
|
||||
assert.strictEqual(VCR().missedCount, 2);
|
||||
ctx.window._liveVcrSetMode('LIVE');
|
||||
});
|
||||
|
||||
test('bufferPacket handles malformed packet without decoded field', () => {
|
||||
const before = VCR().buffer.length;
|
||||
// Packet with no decoded field at all — should not throw, and should still be buffered
|
||||
bufferPacket({ hash: 'malformed1' });
|
||||
assert.strictEqual(VCR().buffer.length, before + 1, 'malformed packet should still be added to buffer');
|
||||
});
|
||||
|
||||
test('bufferPacket handles packet with null decoded', () => {
|
||||
const before = VCR().buffer.length;
|
||||
bufferPacket({ hash: 'malformed2', decoded: null });
|
||||
assert.strictEqual(VCR().buffer.length, before + 1, 'packet with null decoded should still be added to buffer');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== VCR frozenNow behavior =====
|
||||
console.log('\n=== live.js: VCR frozenNow ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const VCR = ctx.window._liveVCR;
|
||||
const setMode = ctx.window._liveVcrSetMode;
|
||||
|
||||
test('frozenNow is set on first non-LIVE mode', () => {
|
||||
setMode('LIVE');
|
||||
assert.strictEqual(VCR().frozenNow, null);
|
||||
setMode('PAUSED');
|
||||
const t1 = VCR().frozenNow;
|
||||
assert.ok(t1 > 0);
|
||||
// Should NOT change on subsequent non-LIVE mode changes
|
||||
setMode('REPLAY');
|
||||
assert.strictEqual(VCR().frozenNow, t1, 'frozenNow should not change if already set');
|
||||
});
|
||||
|
||||
test('frozenNow cleared on LIVE', () => {
|
||||
setMode('PAUSED');
|
||||
assert.ok(VCR().frozenNow != null);
|
||||
setMode('LIVE');
|
||||
assert.strictEqual(VCR().frozenNow, null);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Source-level checks for live.js safety guards =====
|
||||
// NOTE: These src.includes() checks are intentionally brittle — they verify that specific
|
||||
// safety guards exist in the source code TODAY. They will break on whitespace/rename refactors,
|
||||
// which is an acceptable tradeoff: a failing test forces the developer to verify the guard
|
||||
// still exists in its new form. For critical guards (animation limits, null checks), prefer
|
||||
// behavioral tests where feasible (see bufferPacket and VCR sections above).
|
||||
console.log('\n=== live.js: source-level safety checks ===');
|
||||
{
|
||||
const src = fs.readFileSync('public/live.js', 'utf8');
|
||||
|
||||
test('renderPacketTree null-checks packets array', () => {
|
||||
assert.ok(src.includes('if (!packets || !packets.length) return;'),
|
||||
'renderPacketTree must guard null/empty packets');
|
||||
});
|
||||
|
||||
test('animatePath guards MAX_CONCURRENT_ANIMS', () => {
|
||||
assert.ok(src.includes('if (activeAnims >= MAX_CONCURRENT_ANIMS) return;'),
|
||||
'animatePath must respect concurrent animation limit');
|
||||
});
|
||||
|
||||
test('animatePath guards null animLayer/pathsLayer', () => {
|
||||
assert.ok(src.includes('if (!animLayer || !pathsLayer) return;'),
|
||||
'animatePath must guard null layers');
|
||||
});
|
||||
|
||||
test('pulseNode guards null animLayer/nodesLayer', () => {
|
||||
assert.ok(src.includes('if (!animLayer || !nodesLayer) return;'),
|
||||
'pulseNode must guard null layers');
|
||||
});
|
||||
|
||||
test('nextHop guards null animLayer', () => {
|
||||
assert.ok(src.includes('if (!animLayer) return;'),
|
||||
'nextHop must guard null animLayer before drawing');
|
||||
});
|
||||
|
||||
test('VCR buffer trim adjusts playhead', () => {
|
||||
assert.ok(src.includes('VCR.playhead = Math.max(0, VCR.playhead - trimCount)'),
|
||||
'buffer trim must adjust playhead to prevent stale indices');
|
||||
});
|
||||
|
||||
test('tab hidden skips animations', () => {
|
||||
assert.ok(src.includes('if (_tabHidden)'),
|
||||
'bufferPacket should skip animation when tab is hidden');
|
||||
});
|
||||
|
||||
test('visibility change clears propagation buffer', () => {
|
||||
assert.ok(src.includes('propagationBuffer.clear()'),
|
||||
'tab restore should clear propagation buffer');
|
||||
});
|
||||
|
||||
test('connectWS has reconnect on close', () => {
|
||||
assert.ok(src.includes('ws.onclose = () => setTimeout(connectWS, WS_RECONNECT_MS)'),
|
||||
'WebSocket should auto-reconnect on close');
|
||||
});
|
||||
|
||||
test('addNodeMarker avoids duplicates', () => {
|
||||
assert.ok(src.includes('if (nodeMarkers[n.public_key]) return nodeMarkers[n.public_key]'),
|
||||
'addNodeMarker should return existing marker if already exists');
|
||||
});
|
||||
|
||||
test('matrix mode saves toggle to localStorage', () => {
|
||||
assert.ok(src.includes("localStorage.setItem('live-matrix-mode'"),
|
||||
'matrix toggle should persist to localStorage');
|
||||
});
|
||||
|
||||
test('matrix rain saves toggle to localStorage', () => {
|
||||
assert.ok(src.includes("localStorage.setItem('live-matrix-rain'"),
|
||||
'matrix rain toggle should persist to localStorage');
|
||||
});
|
||||
|
||||
test('realistic propagation saves toggle to localStorage', () => {
|
||||
assert.ok(src.includes("localStorage.setItem('live-realistic-propagation'"),
|
||||
'realistic propagation toggle should persist to localStorage');
|
||||
});
|
||||
|
||||
test('favorites filter saves toggle to localStorage', () => {
|
||||
assert.ok(src.includes("localStorage.setItem('live-favorites-only'"),
|
||||
'favorites filter toggle should persist to localStorage');
|
||||
});
|
||||
|
||||
test('ghost hops saves toggle to localStorage', () => {
|
||||
assert.ok(src.includes("localStorage.setItem('live-ghost-hops'"),
|
||||
'ghost hops toggle should persist to localStorage');
|
||||
});
|
||||
|
||||
test('clearNodeMarkers resets HopResolver', () => {
|
||||
assert.ok(src.includes('if (window.HopResolver) HopResolver.init([])'),
|
||||
'clearNodeMarkers should reset HopResolver');
|
||||
});
|
||||
|
||||
test('rescaleMarkers reads zoom from map', () => {
|
||||
assert.ok(src.includes('const zoom = map.getZoom()'),
|
||||
'rescaleMarkers should read current zoom level');
|
||||
});
|
||||
|
||||
test('startReplay pre-aggregates by hash', () => {
|
||||
assert.ok(src.includes('const hashGroups = new Map()'),
|
||||
'startReplay should group buffer entries by hash');
|
||||
});
|
||||
|
||||
test('orientation change retries resize with delays', () => {
|
||||
assert.ok(src.includes('[50, 200, 500, 1000, 2000].forEach'),
|
||||
'orientation change handler should retry resize at multiple intervals');
|
||||
});
|
||||
|
||||
test('VCR rewind deduplicates buffer entries by ID', () => {
|
||||
assert.ok(src.includes('const existingIds = new Set(VCR.buffer.map(b => b.pkt.id)'),
|
||||
'vcrRewind should dedup by packet ID');
|
||||
});
|
||||
|
||||
test('feed items include transport badge', () => {
|
||||
const count = (src.match(/transportBadge\(pkt\.route_type\)/g) || []).length;
|
||||
assert.ok(count >= 3,
|
||||
`feed rendering should call transportBadge(pkt.route_type) in at least 3 places (found ${count})`);
|
||||
});
|
||||
|
||||
test('node detail recent packets include transport badge', () => {
|
||||
assert.ok(src.includes('transportBadge(p.route_type)'),
|
||||
'node detail recent packets should call transportBadge(p.route_type)');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
console.log(` live.js tests: ${passed} passed, ${failed} failed`);
|
||||
console.log(`${'═'.repeat(40)}\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}).catch((e) => {
|
||||
console.error('Failed waiting for async tests:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -757,33 +757,6 @@ 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 =====
|
||||
console.log(`\n${'='.repeat(40)}`);
|
||||
console.log(`packets.js tests: ${passed} passed, ${failed} failed`);
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Show Neighbors E2E tests (#484 fix)
|
||||
* Tests that selectReferenceNode() uses the affinity API instead of client-side path walking.
|
||||
* Usage: CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:13590 node test-show-neighbors.js
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
||||
const results = [];
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
results.push({ name, pass: true });
|
||||
console.log(` ✅ ${name}`);
|
||||
} catch (err) {
|
||||
results.push({ name, pass: false, error: err.message });
|
||||
console.log(` ❌ ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, msg) {
|
||||
if (!condition) throw new Error(msg || 'Assertion failed');
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('Launching Chromium...');
|
||||
const launchOpts = { headless: true, args: ['--no-sandbox', '--disable-gpu'] };
|
||||
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
|
||||
const browser = await chromium.launch(launchOpts);
|
||||
const page = await browser.newPage();
|
||||
|
||||
console.log(`\nRunning Show Neighbors tests against ${BASE}\n`);
|
||||
|
||||
await test('Show Neighbors calls affinity API and populates neighborPubkeys', async () => {
|
||||
const testPubkey = 'aabbccdd11223344556677889900aabbccddeeff00112233445566778899001122';
|
||||
const neighborPubkey1 = '1111111111111111111111111111111111111111111111111111111111111111';
|
||||
const neighborPubkey2 = '2222222222222222222222222222222222222222222222222222222222222222';
|
||||
|
||||
let apiCalled = false;
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
apiCalled = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: testPubkey,
|
||||
neighbors: [
|
||||
{ pubkey: neighborPubkey1, prefix: '11', name: 'Neighbor-1', role: 'repeater', count: 50, score: 0.9, ambiguous: false },
|
||||
{ pubkey: neighborPubkey2, prefix: '22', name: 'Neighbor-2', role: 'companion', count: 20, score: 0.7, ambiguous: false }
|
||||
],
|
||||
total_observations: 70
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const result = await page.evaluate(async (args) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no _mapSelectRefNode function' };
|
||||
if (typeof window._mapGetNeighborPubkeys !== 'function') return { error: 'no _mapGetNeighborPubkeys function' };
|
||||
await window._mapSelectRefNode(args.pk, 'TestNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, { pk: testPubkey });
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(apiCalled, 'The /neighbors API should have been called');
|
||||
assert(result.neighbors.includes(neighborPubkey1), `Should contain neighbor1, got: ${JSON.stringify(result.neighbors)}`);
|
||||
assert(result.neighbors.includes(neighborPubkey2), `Should contain neighbor2, got: ${JSON.stringify(result.neighbors)}`);
|
||||
assert(result.neighbors.length === 2, `Should have exactly 2 neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors resolves correct node on hash collision via affinity API', async () => {
|
||||
const nodeA = 'c0dedad4208acb6cbe44b848943fc6d3c5d43cf38a21e48b43826a70862980e4';
|
||||
const nodeB = 'c0f1a2b3000000000000000000000000000000000000000000000000000000ff';
|
||||
const neighborR1 = 'r1aaaaaa000000000000000000000000000000000000000000000000000000aa';
|
||||
const neighborR2 = 'r2bbbbbb000000000000000000000000000000000000000000000000000000bb';
|
||||
const neighborR4 = 'r4dddddd000000000000000000000000000000000000000000000000000000dd';
|
||||
|
||||
await page.route(`**/api/nodes/${nodeA}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeA,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR1, prefix: 'R1', name: 'Repeater-R1', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
|
||||
{ pubkey: neighborR2, prefix: 'R2', name: 'Repeater-R2', role: 'repeater', count: 80, score: 0.85, ambiguous: false }
|
||||
],
|
||||
total_observations: 180
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${nodeB}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeB,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR4, prefix: 'R4', name: 'Repeater-R4', role: 'repeater', count: 60, score: 0.75, ambiguous: false }
|
||||
],
|
||||
total_observations: 60
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Select Node A — should get R1, R2 but NOT R4
|
||||
const resultA = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeA');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeA);
|
||||
assert(resultA.includes(neighborR1), 'Node A should have R1 as neighbor');
|
||||
assert(resultA.includes(neighborR2), 'Node A should have R2 as neighbor');
|
||||
assert(!resultA.includes(neighborR4), 'Node A should NOT have R4 (that belongs to Node B)');
|
||||
|
||||
// Select Node B — should get R4 but NOT R1, R2
|
||||
const resultB = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeB');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeB);
|
||||
assert(resultB.includes(neighborR4), 'Node B should have R4 as neighbor');
|
||||
assert(!resultB.includes(neighborR1), 'Node B should NOT have R1 (that belongs to Node A)');
|
||||
assert(!resultB.includes(neighborR2), 'Node B should NOT have R2 (that belongs to Node A)');
|
||||
|
||||
await page.unroute(`**/api/nodes/${nodeA}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${nodeB}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors falls back to path walking when affinity API returns empty', async () => {
|
||||
const testPubkey = 'fallbacktest0000000000000000000000000000000000000000000000000000';
|
||||
const hopBefore = 'aaaa000000000000000000000000000000000000000000000000000000000000';
|
||||
const hopAfter = 'bbbb000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
let neighborApiCalled = false;
|
||||
let pathsApiCalled = false;
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
neighborApiCalled = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ node: testPubkey, neighbors: [], total_observations: 0 })
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/paths*`, route => {
|
||||
pathsApiCalled = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
paths: [{
|
||||
hops: [
|
||||
{ pubkey: hopBefore, name: 'HopBefore' },
|
||||
{ pubkey: testPubkey, name: 'Self' },
|
||||
{ pubkey: hopAfter, name: 'HopAfter' }
|
||||
]
|
||||
}]
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const result = await page.evaluate(async (pk) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no-function' };
|
||||
await window._mapSelectRefNode(pk, 'FallbackNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, testPubkey);
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(neighborApiCalled, 'Should try neighbor API first');
|
||||
assert(pathsApiCalled, 'Should fall back to paths API when neighbors empty');
|
||||
assert(result.neighbors.includes(hopBefore), 'Fallback should find hopBefore as neighbor');
|
||||
assert(result.neighbors.includes(hopAfter), 'Fallback should find hopAfter as neighbor');
|
||||
assert(result.neighbors.length === 2, `Fallback should find exactly 2 neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/paths*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors includes ambiguous candidates in neighborPubkeys', async () => {
|
||||
const testPubkey = 'ambigtest000000000000000000000000000000000000000000000000000000';
|
||||
const candidate1 = 'a3b4c500000000000000000000000000000000000000000000000000000000';
|
||||
const candidate2 = 'a3f0e100000000000000000000000000000000000000000000000000000000';
|
||||
const knownNeighbor = 'b7e8f9a000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: testPubkey,
|
||||
neighbors: [
|
||||
{ pubkey: knownNeighbor, prefix: 'B7', name: 'Known-Neighbor', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
|
||||
{ pubkey: null, prefix: 'A3', name: null, role: null, count: 12, score: 0.08, ambiguous: true,
|
||||
candidates: [
|
||||
{ pubkey: candidate1, name: 'Node-Alpha', role: 'companion' },
|
||||
{ pubkey: candidate2, name: 'Node-Beta', role: 'companion' }
|
||||
]
|
||||
}
|
||||
],
|
||||
total_observations: 112
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const result = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'AmbigNode');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, testPubkey);
|
||||
|
||||
// Should include the known neighbor AND both ambiguous candidates
|
||||
assert(result.includes(knownNeighbor), 'Should include known neighbor');
|
||||
assert(result.includes(candidate1), 'Should include ambiguous candidate 1');
|
||||
assert(result.includes(candidate2), 'Should include ambiguous candidate 2');
|
||||
assert(result.length === 3, `Should have 3 neighbors (1 known + 2 candidates), got ${result.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
const passed = results.filter(r => r.pass).length;
|
||||
const failed = results.filter(r => !r.pass).length;
|
||||
console.log(`\n${passed}/${results.length} tests passed${failed ? `, ${failed} failed` : ''}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user