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)}`);