mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 12:32:34 +00:00
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:
1 parent
99dc4f805a
commit
ceea136e97
5 files changed
+146
-1
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
--status-red: #ef4444;
|
||||
--status-orange: #f97316;
|
||||
--status-purple: #a855f7;
|
||||
--role-observer: #8b5cf6;
|
||||
--accent-hover: #6db3ff;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #5b6370;
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
Reference in new issue
Block a user