mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-22 19:45:47 +00:00
## Summary Milestone 6 of #482: Observability & Debugging tools for the neighbor affinity system. These tools exist because someone will need them at 3 AM when "Show Neighbors is showing the wrong node for C0DE" and they have 5 minutes to diagnose it. ## Changes ### 1. Debug API — `GET /api/debug/affinity` - Full graph state dump: all edges with weights, observation counts, last-seen timestamps - Per-prefix resolution log with disambiguation reasoning (Jaccard scores, ratios, thresholds) - Query params: `?prefix=C0DE` filter to specific prefix, `?node=<pubkey>` for specific node's edges - Protected by API key (same auth as `/api/admin/prune`) - Response includes: edge count, node count, cache age, last rebuild time ### 2. Debug Overlay on Map - Toggle-able checkbox "🔍 Affinity Debug" in map controls - Draws lines between nodes showing affinity edges with color coding: - Green = high confidence (score ≥ 0.6) - Yellow = medium (0.3–0.6) - Red = ambiguous (< 0.3) - Line thickness proportional to weight, dashed for ambiguous - Unresolved prefixes shown as ❓ markers - Click edge → popup with observation count, last seen, score, observers - Hidden behind `debugAffinity` config flag or `localStorage.setItem('meshcore-affinity-debug', 'true')` ### 3. Per-Node Debug Panel - Expandable "🔍 Affinity Debug" section in node detail page (collapsed by default) - Shows: neighbor edges table with scores, prefix resolutions with reasoning trace - Candidates table with Jaccard scores, highlighting the chosen candidate - Graph-level stats summary ### 4. Server-Side Structured Logging - Integrated into `disambiguate()` — logs every resolution decision during graph build - Format: `[affinity] resolve C0DE: c0dedad4 score=47 Jaccard=0.82 vs c0dedad9 score=3 Jaccard=0.11 → neighbor_affinity (ratio 15.7×)` - Logs ambiguous decisions: `scores too close (12 vs 9, ratio 1.3×) → ambiguous` - Gated by `debugAffinity` config flag ### 5. Dashboard Stats Widget - Added to analytics overview tab when debug mode is enabled - Metrics: total edges/nodes, resolved/ambiguous counts (%), avg confidence, cold-start coverage, cache age, last rebuild ## Files Changed - `cmd/server/neighbor_debug.go` — new: debug API handler, resolution builder, cold-start coverage - `cmd/server/neighbor_debug_test.go` — new: 7 tests for debug API - `cmd/server/neighbor_graph.go` — added structured logging to disambiguate(), `logFn` field, `BuildFromStoreWithLog` - `cmd/server/neighbor_api.go` — pass debug flag through `BuildFromStoreWithLog` - `cmd/server/config.go` — added `DebugAffinity` config field - `cmd/server/routes.go` — registered `/api/debug/affinity` route, exposed `debugAffinity` in client config - `cmd/server/types.go` — added `DebugAffinity` to `ClientConfigResponse` - `public/map.js` — affinity debug overlay layer with edge visualization - `public/nodes.js` — per-node affinity debug panel - `public/analytics.js` — dashboard stats widget - `test-e2e-playwright.js` — 3 Playwright tests for debug UI ## Tests - ✅ 7 Go unit tests (API shape, prefix/node filters, auth, structured logging, cold-start coverage) - ✅ 3 Playwright E2E tests (overlay checkbox, toggle without crash, panel expansion) - ✅ All existing tests pass (`go test ./cmd/server/... -count=1`) Part of #482 --------- Co-authored-by: you <you@example.com>
224 lines
6.7 KiB
Go
224 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|