From ceea136e9757c2551b0452645514d344c684bf77 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Thu, 16 Apr 2026 21:35:14 -0700 Subject: [PATCH] feat: observer graph representation (M1+M2) (#774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #753 — Milestones M1 and M2: Observer nodes in the neighbor graph are now correctly labeled, colored, and filterable. ### M1: Label + color observers **Backend** (`cmd/server/neighbor_api.go`): - `buildNodeInfoMap()` now queries the `observers` table after building from `nodes` - Observer-only pubkeys (not already in the map as repeaters etc.) get `role: "observer"` and their name from the observers table - Observer-repeaters keep their repeater role (not overwritten) **Frontend**: - CSS variable `--role-observer: #8b5cf6` added to `:root` - `ROLE_COLORS.observer` was already defined in `roles.js` ### M2: Observer filter checkbox (default unchecked) **Frontend** (`public/analytics.js`): - Observer checkbox added to the role filter section, **unchecked by default** - Observers create hub-and-spoke patterns (one observer can have 100+ edges) that drown out the actual repeater topology — hiding them by default keeps the graph clean - Fixed `applyNGFilters()` which previously always showed observers regardless of checkbox state ### Tests - Backend: `TestBuildNodeInfoMap_ObserverEnrichment` — verifies observer-only pubkeys get name+role from observers table, and observer-repeaters keep their repeater role - All existing Go tests pass - All frontend helper tests pass (544/544) --------- Co-authored-by: you --- cmd/server/neighbor_api.go | 20 ++++++++++ cmd/server/neighbor_api_test.go | 68 +++++++++++++++++++++++++++++++++ public/analytics.js | 7 +++- public/style.css | 1 + test-frontend-helpers.js | 51 +++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 1 deletion(-) diff --git a/cmd/server/neighbor_api.go b/cmd/server/neighbor_api.go index 40139326..7bde3418 100644 --- a/cmd/server/neighbor_api.go +++ b/cmd/server/neighbor_api.go @@ -373,6 +373,26 @@ func (s *Server) buildNodeInfoMap() map[string]nodeInfo { for _, n := range nodes { m[strings.ToLower(n.PublicKey)] = n } + + // Enrich observer-only nodes: if an observer pubkey isn't already in the + // map (i.e. it's not also a repeater/companion), add it with role "observer". + if s.db != nil { + rows, err := s.db.conn.Query("SELECT id, name FROM observers") + if err == nil { + defer rows.Close() + for rows.Next() { + var id, name string + if rows.Scan(&id, &name) != nil { + continue + } + key := strings.ToLower(id) + if _, exists := m[key]; !exists { + m[key] = nodeInfo{PublicKey: id, Name: name, Role: "observer"} + } + } + } + } + return m } diff --git a/cmd/server/neighbor_api_test.go b/cmd/server/neighbor_api_test.go index 5b000c51..1510c515 100644 --- a/cmd/server/neighbor_api_test.go +++ b/cmd/server/neighbor_api_test.go @@ -1,6 +1,7 @@ package main import ( + "database/sql" "encoding/json" "net/http" "net/http/httptest" @@ -8,6 +9,7 @@ import ( "time" "github.com/gorilla/mux" + _ "modernc.org/sqlite" ) // ─── Helpers ─────────────────────────────────────────────────────────────────── @@ -457,3 +459,69 @@ func TestNeighborGraphAPI_ResponseShape(t *testing.T) { } } } + +// ─── Tests: buildNodeInfoMap observer enrichment (#753) ──────────────────────── + +func TestBuildNodeInfoMap_ObserverEnrichment(t *testing.T) { + // Create a temp SQLite DB with nodes and observers tables. + tmpDir := t.TempDir() + dbPath := tmpDir + "/test.db" + + conn, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + // Create tables + for _, stmt := range []string{ + "CREATE TABLE nodes (public_key TEXT, name TEXT, role TEXT, lat REAL, lon REAL)", + "CREATE TABLE observers (id TEXT, name TEXT)", + "INSERT INTO nodes VALUES ('AAAA1111', 'Repeater-1', 'repeater', 0, 0)", + "INSERT INTO observers VALUES ('BBBB2222', 'Observer-Alpha')", + "INSERT INTO observers VALUES ('AAAA1111', 'Obs-also-repeater')", + } { + if _, err := conn.Exec(stmt); err != nil { + t.Fatalf("exec %q: %v", stmt, err) + } + } + conn.Close() + + // Open via our DB wrapper + db, err := OpenDB(dbPath) + if err != nil { + t.Fatal(err) + } + defer db.conn.Close() + + // Build a PacketStore with this DB (minimal — just need getCachedNodesAndPM) + store := NewPacketStore(db, nil) + store.Load() + + srv := &Server{ + db: db, + store: store, + perfStats: NewPerfStats(), + } + + m := srv.buildNodeInfoMap() + + // AAAA1111 should be from nodes table (repeater), NOT overwritten by observer + if info, ok := m["aaaa1111"]; !ok { + t.Error("expected aaaa1111 in map") + } else if info.Role != "repeater" { + t.Errorf("expected role=repeater for aaaa1111, got %q", info.Role) + } + + // BBBB2222 should be enriched from observers table + if info, ok := m["bbbb2222"]; !ok { + t.Error("expected bbbb2222 in map (observer-only node)") + } else { + if info.Role != "observer" { + t.Errorf("expected role=observer for bbbb2222, got %q", info.Role) + } + if info.Name != "Observer-Alpha" { + t.Errorf("expected name=Observer-Alpha for bbbb2222, got %q", info.Name) + } + } +} diff --git a/public/analytics.js b/public/analytics.js index 127cda12..e48edd0c 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2045,6 +2045,11 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ const color = (window.ROLE_COLORS || {})[r] || '#888'; rcEl.innerHTML += ``; }); + // Observer checkbox — unchecked by default (observers create hub-and-spoke noise) + { + const color = (window.ROLE_COLORS || {}).observer || '#8b5cf6'; + rcEl.innerHTML += ``; + } // Load data const rqs = RegionFilter.regionQueryString(); @@ -2102,7 +2107,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ // Filter nodes by role const visibleNodes = _ngState.allNodes.filter(n => { const role = (n.role || 'unknown').toLowerCase(); - return checkedRoles.has(role) || role === 'unknown' || role === 'observer'; + return checkedRoles.has(role) || role === 'unknown'; }); const visiblePKs = new Set(visibleNodes.map(n => n.pubkey)); diff --git a/public/style.css b/public/style.css index 2d0db453..68374c8a 100644 --- a/public/style.css +++ b/public/style.css @@ -13,6 +13,7 @@ --status-red: #ef4444; --status-orange: #f97316; --status-purple: #a855f7; + --role-observer: #8b5cf6; --accent-hover: #6db3ff; --text: #1a1a2e; --text-muted: #5b6370; diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index e7df8576..2d68180d 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -5513,6 +5513,57 @@ console.log('\n=== analytics.js: renderCollisionsFromServer collision table ===' }); } +// ===== Observer role support (#753 / PR #774) ===== +{ + console.log('\n--- Observer role support (PR #774) ---'); + + // Test 1: ROLE_COLORS.observer is defined and not empty + test('ROLE_COLORS.observer is defined and not empty', () => { + const rolesJs = fs.readFileSync(__dirname + '/public/roles.js', 'utf8'); + const ctx = makeSandbox(); + vm.runInNewContext(rolesJs, ctx); + assert.ok(ctx.window.ROLE_COLORS.observer, 'ROLE_COLORS.observer should be defined'); + assert.ok(ctx.window.ROLE_COLORS.observer.length > 0, 'ROLE_COLORS.observer should not be empty'); + }); + + // Test 2: Observer checkbox exists in neighbor graph filter (unchecked by default) + test('Observer checkbox exists in neighbor graph filter section', () => { + const analyticsJs = fs.readFileSync(__dirname + '/public/analytics.js', 'utf8'); + // The observer checkbox is added with data-role="observer" and NO "checked" attribute + assert.ok(analyticsJs.includes('data-role="observer"'), 'analytics.js should contain observer checkbox'); + // Verify it's NOT checked by default (no "checked" attribute on observer checkbox) + const observerCheckboxMatch = analyticsJs.match(/data-role="observer"[^>]*>/); + assert.ok(observerCheckboxMatch, 'observer checkbox markup must exist'); + assert.ok(!observerCheckboxMatch[0].includes('checked'), 'observer checkbox should NOT be checked by default'); + }); + + // Test 3: Other role checkboxes ARE checked by default + test('Non-observer role checkboxes are checked by default', () => { + const analyticsJs = fs.readFileSync(__dirname + '/public/analytics.js', 'utf8'); + // The main role loop uses "checked" attribute + const mainRoleCheckbox = analyticsJs.match(/data-role="\$\{r\}"[^>]*checked/); + assert.ok(mainRoleCheckbox, 'Main role checkboxes should have checked attribute'); + }); + + // Test 4: --role-observer CSS variable exists in style.css + test('--role-observer CSS variable exists in style.css', () => { + const css = fs.readFileSync(__dirname + '/public/style.css', 'utf8'); + assert.ok(css.includes('--role-observer:'), 'style.css should define --role-observer CSS variable'); + }); + + // Test 5: Filter logic does NOT auto-include observer role + test('Filter logic excludes observer nodes when checkbox unchecked', () => { + const analyticsJs = fs.readFileSync(__dirname + '/public/analytics.js', 'utf8'); + // Old code had: return checkedRoles.has(role) || role === 'unknown' || role === 'observer'; + // New code: return checkedRoles.has(role) || role === 'unknown'; + // Verify observer is NOT given special pass-through treatment + const filterLine = analyticsJs.match(/return checkedRoles\.has\(role\)[^;]+;/); + assert.ok(filterLine, 'filter line must exist'); + assert.ok(!filterLine[0].includes("'observer'"), 'filter should NOT auto-include observer role'); + assert.ok(filterLine[0].includes("'unknown'"), 'filter should still auto-include unknown role'); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);