feat: observer graph representation (M1+M2) (#774)

## 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 <you@example.com>
This commit is contained in:
Kpa-clawbotandyou authored and GitHub committed 2026-04-16 21:35:14 -07:00
1 parent 99dc4f805a
commit ceea136e97
5 files changed
+146 -1

No files matched your search

+20
View File
@@ -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
}
+68
View File
@@ -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)
}
}
}
+6 -1
View File
@@ -2045,6 +2045,11 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
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>`;
});
// Observer checkbox — unchecked by default (observers create hub-and-spoke noise)
{
const color = (window.ROLE_COLORS || {}).observer || '#8b5cf6';
rcEl.innerHTML += `<label style="font-size:12px;margin-right:8px"><input type="checkbox" data-role="observer"> <span style="color:${esc(color)}">observer</span></label>`;
}
// 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));
+1
View File
@@ -13,6 +13,7 @@
--status-red: #ef4444;
--status-orange: #f97316;
--status-purple: #a855f7;
--role-observer: #8b5cf6;
--accent-hover: #6db3ff;
--text: #1a1a2e;
--text-muted: #5b6370;
+51
View File
@@ -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)}`);