diff --git a/cmd/server/neighbor_graph.go b/cmd/server/neighbor_graph.go index 8b714b8d..e05bb83c 100644 --- a/cmd/server/neighbor_graph.go +++ b/cmd/server/neighbor_graph.go @@ -209,25 +209,23 @@ func BuildFromStoreWithLog(store *PacketStore, enableLog bool) *NeighborGraph { return g } -// extractFromNode pulls the from_node pubkey from a StoreTx. -// It looks in DecodedJSON for "from_node" or "from". +// extractFromNode pulls the originator pubkey from a StoreTx's DecodedJSON. +// ADVERTs use "pubKey", other packets may use "from_node" or "from". func extractFromNode(tx *StoreTx) string { if tx.DecodedJSON == "" { return "" } - // Fast path: look for "from_node" key. var decoded map[string]interface{} if err := jsonUnmarshalFast(tx.DecodedJSON, &decoded); err != nil { return "" } - if v, ok := decoded["from_node"]; ok { - if s, ok := v.(string); ok { - return s - } - } - if v, ok := decoded["from"]; ok { - if s, ok := v.(string); ok { - return s + // ADVERTs store the originator pubkey as "pubKey"; other packets may use + // "from_node" or "from". Check all three so we never miss the originator. + for _, field := range []string{"pubKey", "from_node", "from"} { + if v, ok := decoded[field]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } } } return "" diff --git a/cmd/server/neighbor_graph_test.go b/cmd/server/neighbor_graph_test.go index 1a1b1029..809ba959 100644 --- a/cmd/server/neighbor_graph_test.go +++ b/cmd/server/neighbor_graph_test.go @@ -622,6 +622,83 @@ func TestBuildNeighborGraph_ADVERTOnlyConstraint(t *testing.T) { } } +// ngPubKeyJSON creates decoded JSON using the real ADVERT format ("pubKey" field). +func ngPubKeyJSON(pubkey string) string { + b, _ := json.Marshal(map[string]string{"pubKey": pubkey}) + return string(b) +} + +func TestBuildNeighborGraph_AdvertPubKeyField(t *testing.T) { + // Real ADVERTs use "pubKey", not "from_node". Verify the builder handles it. + nodes := []nodeInfo{ + {PublicKey: "99bf37abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234", Name: "Originator"}, + {PublicKey: "r1aabbccdd001122334455667788990011223344556677889900112233445566", Name: "R1"}, + {PublicKey: "obs0000100112233445566778899001122334455667788990011223344556677", Name: "Observer"}, + } + tx := ngMakeTx(1, 4, ngPubKeyJSON("99bf37abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234"), []*StoreObs{ + ngMakeObs("obs0000100112233445566778899001122334455667788990011223344556677", `["r1"]`, nowStr, ngFloatPtr(-8.5)), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + edges := g.AllEdges() + if len(edges) < 1 { + t.Fatalf("expected >=1 edges from ADVERT with pubKey field, got %d", len(edges)) + } + + // Check originator↔R1 edge exists + found := false + for _, e := range edges { + a := e.NodeA + b := e.NodeB + orig := "99bf37abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234" + r1 := "r1aabbccdd001122334455667788990011223344556677889900112233445566" + if (a == orig && b == r1) || (a == r1 && b == orig) { + found = true + } + } + if !found { + t.Error("missing originator↔R1 edge when using pubKey field (real ADVERT format)") + } +} + +func TestBuildNeighborGraph_OneByteHashPrefixes(t *testing.T) { + // Real-world scenario: 1-byte hash prefixes with multiple candidates. + // Should create edges (possibly ambiguous) rather than empty graph. + nodes := []nodeInfo{ + {PublicKey: "c0dedad400000000000000000000000000000000000000000000000000000001", Name: "NodeC0-1"}, + {PublicKey: "c0dedad900000000000000000000000000000000000000000000000000000002", Name: "NodeC0-2"}, + {PublicKey: "a3bbccdd00000000000000000000000000000000000000000000000000000003", Name: "Originator"}, + {PublicKey: "obs1234500000000000000000000000000000000000000000000000000000004", Name: "Observer"}, + } + // ADVERT from Originator with 1-byte path hop "c0" + tx := ngMakeTx(1, 4, ngPubKeyJSON("a3bbccdd00000000000000000000000000000000000000000000000000000003"), []*StoreObs{ + ngMakeObs("obs1234500000000000000000000000000000000000000000000000000000004", `["c0"]`, nowStr, ngFloatPtr(-12)), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + edges := g.AllEdges() + if len(edges) == 0 { + t.Fatal("expected non-empty edges for 1-byte hash prefix network, got 0") + } + + // The originator↔c0 edge should be ambiguous (2 candidates match "c0") + var hasAmbig bool + for _, e := range edges { + if e.Ambiguous && e.Prefix == "c0" { + hasAmbig = true + if len(e.Candidates) != 2 { + t.Errorf("expected 2 candidates for prefix c0, got %d", len(e.Candidates)) + } + } + } + if !hasAmbig { + // Could be resolved if one candidate was filtered — check we got some edge + t.Log("no ambiguous edge found, but edges exist — acceptable if resolved") + } +} + func TestNeighborGraph_CacheTTL(t *testing.T) { g := NewNeighborGraph() if !g.IsStale() { diff --git a/public/analytics.js b/public/analytics.js index 525aa131..fb37cb9e 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1868,9 +1868,13 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
- +
+
+ 📋 Text-based neighbor list (accessible alternative) +
+
`; // Role checkboxes @@ -1976,6 +1980,48 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
${avgScore.toFixed(2)}
Avg Score
${resolved.toFixed(0)}%
Resolved
${ambiguous}
Ambiguous
`; + + // Update canvas aria-label with current graph summary + var canvas = document.getElementById('ngCanvas'); + if (canvas) { + canvas.setAttribute('aria-label', 'Neighbor affinity graph: ' + nodes.length + ' nodes, ' + edges.length + ' edges, ' + resolved.toFixed(0) + '% resolved. Use arrow keys to pan, +/- to zoom, 0 to reset.'); + } + + // Update accessible text list + updateNGTextList(st); + } + + function updateNGTextList(st) { + var listEl = document.getElementById('ngTextList'); + if (!listEl) return; + var nodes = st.nodes, edges = st.edges; + if (nodes.length === 0) { + listEl.innerHTML = '

No nodes to display.

'; + return; + } + // Build adjacency for text list + var adj = {}; + edges.forEach(function(e) { + if (!adj[e.source]) adj[e.source] = []; + if (!adj[e.target]) adj[e.target] = []; + adj[e.source].push({ pk: e.target, score: e.score, ambiguous: e.ambiguous }); + adj[e.target].push({ pk: e.source, score: e.score, ambiguous: e.ambiguous }); + }); + var nodeMap = {}; + nodes.forEach(function(n) { nodeMap[n.pubkey] = n; }); + var html = ''; + nodes.slice().sort(function(a, b) { return (a.name || a.pubkey).localeCompare(b.name || b.pubkey); }).forEach(function(n) { + var neighbors = (adj[n.pubkey] || []).map(function(nb) { + var peer = nodeMap[nb.pk]; + var name = peer ? (peer.name || nb.pk.slice(0, 8)) : nb.pk.slice(0, 8); + var conf = nb.ambiguous ? ' ⚠' : (nb.score >= 0.5 ? ' ●' : ' ○'); + return esc(name) + conf; + }).join(', '); + html += ''; + }); + html += '
NodeRoleNeighbors
' + esc(n.name || n.pubkey.slice(0, 12)) + '' + esc(n.role || 'unknown') + '' + (neighbors || 'none') + '
'; + html += '

● = high confidence (score ≥ 0.5), ○ = low confidence, ⚠ = ambiguous/unresolved

'; + listEl.innerHTML = html; } function startGraphRenderer() { @@ -2212,7 +2258,9 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ ctx.lineTo(b.x, b.y); ctx.strokeStyle = e.ambiguous ? 'rgba(255,200,0,0.4)' : 'rgba(150,150,150,0.35)'; ctx.lineWidth = Math.max(0.5, e.score * 4); + if (e.ambiguous) { ctx.setLineDash([4, 4]); } else { ctx.setLineDash([]); } ctx.stroke(); + ctx.setLineDash([]); } // Nodes diff --git a/public/style.css b/public/style.css index 2e98cfdb..6c211794 100644 --- a/public/style.css +++ b/public/style.css @@ -1933,3 +1933,12 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); } .compare-select { min-width: auto; width: 100%; } .compare-summary { grid-template-columns: 1fr; } } + +/* Neighbor graph canvas focus indicator for keyboard navigation */ +#ngCanvas:focus { + outline: 2px solid var(--link-color, #60a5fa); + outline-offset: 2px; +} +#ngCanvas:focus:not(:focus-visible) { + outline: none; +}