mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-04 23:31:26 +00:00
## Fix: Multi-Byte Adopters Table — Three Bugs (#754) ### Bug 1: Companions in "Unknown" `computeMultiByteCapability()` was repeater-only. Extended to classify **all node types** (companions, rooms, sensors). A companion advertising with 2-byte hash is now correctly "Confirmed". ### Bug 2: No Role Column Added a **Role** column to the merged Multi-Byte Hash Adopters table, color-coded using `ROLE_COLORS` from `roles.js`. Users can now distinguish repeaters from companions without clicking through to node detail. ### Bug 3: Data Source Disagreement When adopter data (from `computeAnalyticsHashSizes`) shows `hashSize >= 2` but capability only found path evidence ("Suspected"), the advert-based adopter data now takes precedence → "Confirmed". The adopter hash sizes are passed into `computeMultiByteCapability()` as an additional confirmed evidence source. ### Changes - `cmd/server/store.go`: Extended capability to all node types, accept adopter hash sizes, prioritize advert evidence - `public/analytics.js`: Added Role column with color-coded badges - `cmd/server/multibyte_capability_test.go`: 3 new tests (companion confirmed, role populated, adopter precedence) ### Tests - All 10 multi-byte capability tests pass - All 544 frontend helper tests pass - All 62 packet filter tests pass - All 29 aging tests pass --------- Co-authored-by: you <you@example.com>
This commit is contained in:
@@ -85,7 +85,7 @@ func TestMultiByteCapability_Confirmed(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
caps := store.computeMultiByteCapability(nil)
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func TestMultiByteCapability_Suspected(t *testing.T) {
|
||||
}
|
||||
addTestPacket(store, pkt)
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
caps := store.computeMultiByteCapability(nil)
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func TestMultiByteCapability_Unknown(t *testing.T) {
|
||||
// Advert with 1-byte hash only
|
||||
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 1))
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
caps := store.computeMultiByteCapability(nil)
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
@@ -194,7 +194,7 @@ func TestMultiByteCapability_PrefixCollision(t *testing.T) {
|
||||
}
|
||||
addTestPacket(store, pkt)
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
caps := store.computeMultiByteCapability(nil)
|
||||
if len(caps) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(caps))
|
||||
}
|
||||
@@ -237,7 +237,7 @@ func TestMultiByteCapability_TraceExcluded(t *testing.T) {
|
||||
}
|
||||
addTestPacket(store, pkt)
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
caps := store.computeMultiByteCapability(nil)
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
@@ -269,7 +269,7 @@ func TestMultiByteCapability_NonTraceStillSuspected(t *testing.T) {
|
||||
}
|
||||
addTestPacket(store, pkt)
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
caps := store.computeMultiByteCapability(nil)
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion(t *testing.T) {
|
||||
}
|
||||
addTestPacket(store, pkt)
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
caps := store.computeMultiByteCapability(nil)
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
@@ -312,3 +312,117 @@ func TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion(t *testing.T) {
|
||||
t.Errorf("expected confirmed (unaffected by TRACE), got %s", caps[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiByteCapability_CompanionConfirmed tests that a companion with
|
||||
// multi-byte advert is classified as "confirmed", not "unknown" (Bug 1, #754).
|
||||
func TestMultiByteCapability_CompanionConfirmed(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"aabbccdd11223344", "CompA", "companion", "2026-04-11T00:00:00Z")
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
|
||||
|
||||
caps := store.computeMultiByteCapability(nil)
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
if caps[0].Status != "confirmed" {
|
||||
t.Errorf("expected confirmed for companion, got %s", caps[0].Status)
|
||||
}
|
||||
if caps[0].Role != "companion" {
|
||||
t.Errorf("expected role companion, got %s", caps[0].Role)
|
||||
}
|
||||
if caps[0].Evidence != "advert" {
|
||||
t.Errorf("expected advert evidence, got %s", caps[0].Evidence)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiByteCapability_RoleColumnPopulated tests that the Role field is
|
||||
// populated for all node types (Bug 2, #754).
|
||||
func TestMultiByteCapability_RoleColumnPopulated(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"aabb000000000001", "Rep1", "repeater", "2026-04-11T00:00:00Z")
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"ccdd000000000002", "Comp1", "companion", "2026-04-11T00:00:00Z")
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"eeff000000000003", "Room1", "room_server", "2026-04-11T00:00:00Z")
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
addTestPacket(store, makeTestAdvert("aabb000000000001", 2))
|
||||
addTestPacket(store, makeTestAdvert("ccdd000000000002", 2))
|
||||
addTestPacket(store, makeTestAdvert("eeff000000000003", 1))
|
||||
|
||||
caps := store.computeMultiByteCapability(nil)
|
||||
if len(caps) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(caps))
|
||||
}
|
||||
|
||||
roleByName := map[string]string{}
|
||||
for _, c := range caps {
|
||||
roleByName[c.Name] = c.Role
|
||||
}
|
||||
if roleByName["Rep1"] != "repeater" {
|
||||
t.Errorf("Rep1 role: expected repeater, got %s", roleByName["Rep1"])
|
||||
}
|
||||
if roleByName["Comp1"] != "companion" {
|
||||
t.Errorf("Comp1 role: expected companion, got %s", roleByName["Comp1"])
|
||||
}
|
||||
if roleByName["Room1"] != "room_server" {
|
||||
t.Errorf("Room1 role: expected room_server, got %s", roleByName["Room1"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiByteCapability_AdopterEvidenceTakesPrecedence tests that when
|
||||
// adopter data shows hashSize >= 2 but path evidence says "suspected",
|
||||
// the node is upgraded to "confirmed" (Bug 3, #754).
|
||||
func TestMultiByteCapability_AdopterEvidenceTakesPrecedence(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"aabbccdd11223344", "RepAdopter", "repeater", "2026-04-11T00:00:00Z")
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// Only a path-based packet (no advert) — would normally be "suspected"
|
||||
pathByte := buildPathByte(2, 1)
|
||||
rawHex := "01" + pathByte + "aabb"
|
||||
pt := 1
|
||||
pkt := &StoreTx{
|
||||
RawHex: rawHex,
|
||||
PayloadType: &pt,
|
||||
PathJSON: `["aabb"]`,
|
||||
FirstSeen: "2026-04-10T00:00:00.000Z",
|
||||
}
|
||||
addTestPacket(store, pkt)
|
||||
|
||||
// Without adopter data: should be suspected
|
||||
caps := store.computeMultiByteCapability(nil)
|
||||
capByName := map[string]MultiByteCapEntry{}
|
||||
for _, c := range caps {
|
||||
capByName[c.Name] = c
|
||||
}
|
||||
if capByName["RepAdopter"].Status != "suspected" {
|
||||
t.Errorf("without adopter data: expected suspected, got %s", capByName["RepAdopter"].Status)
|
||||
}
|
||||
|
||||
// With adopter data showing hashSize 2: should be confirmed
|
||||
adopterHS := map[string]int{"aabbccdd11223344": 2}
|
||||
caps = store.computeMultiByteCapability(adopterHS)
|
||||
capByName = map[string]MultiByteCapEntry{}
|
||||
for _, c := range caps {
|
||||
capByName[c.Name] = c
|
||||
}
|
||||
if capByName["RepAdopter"].Status != "confirmed" {
|
||||
t.Errorf("with adopter data: expected confirmed, got %s", capByName["RepAdopter"].Status)
|
||||
}
|
||||
if capByName["RepAdopter"].Evidence != "advert" {
|
||||
t.Errorf("with adopter data: expected advert evidence, got %s", capByName["RepAdopter"].Evidence)
|
||||
}
|
||||
}
|
||||
|
||||
+27
-16
@@ -5105,7 +5105,18 @@ func (s *PacketStore) GetAnalyticsHashSizes(region string) map[string]interface{
|
||||
|
||||
// Add multi-byte capability data (only for unfiltered/global view)
|
||||
if region == "" {
|
||||
result["multiByteCapability"] = s.computeMultiByteCapability()
|
||||
// Pass adopter hash sizes so capability can cross-reference
|
||||
adopterHS := make(map[string]int)
|
||||
if mbNodes, ok := result["multiByteNodes"].([]map[string]interface{}); ok {
|
||||
for _, n := range mbNodes {
|
||||
pk, _ := n["pubkey"].(string)
|
||||
hs, _ := n["hashSize"].(int)
|
||||
if pk != "" && hs >= 2 {
|
||||
adopterHS[pk] = hs
|
||||
}
|
||||
}
|
||||
}
|
||||
result["multiByteCapability"] = s.computeMultiByteCapability(adopterHS)
|
||||
}
|
||||
|
||||
s.cacheMu.Lock()
|
||||
@@ -5818,7 +5829,7 @@ func EnrichNodeWithHashSize(node map[string]interface{}, info *hashSizeNodeInfo)
|
||||
|
||||
// --- Multi-Byte Capability Inference ---
|
||||
|
||||
// MultiByteCapEntry represents a repeater's inferred multi-byte capability.
|
||||
// MultiByteCapEntry represents a node's inferred multi-byte capability.
|
||||
type MultiByteCapEntry struct {
|
||||
PublicKey string `json:"pubkey"`
|
||||
Name string `json:"name"`
|
||||
@@ -5830,7 +5841,7 @@ type MultiByteCapEntry struct {
|
||||
}
|
||||
|
||||
// computeMultiByteCapability determines multi-byte capability for each
|
||||
// repeater using two methods:
|
||||
// node (repeaters, companions, rooms, sensors) using two methods:
|
||||
//
|
||||
// 1. Confirmed: the node has advertised with hash_size >= 2 (from advert
|
||||
// path byte). This is 100% reliable because the full public key is
|
||||
@@ -5847,7 +5858,7 @@ type MultiByteCapEntry struct {
|
||||
// with default (1-byte) settings.
|
||||
//
|
||||
// Caller must hold NO locks — this method acquires mu.RLock internally.
|
||||
func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry {
|
||||
func (s *PacketStore) computeMultiByteCapability(adopterHashSizes map[string]int) []MultiByteCapEntry {
|
||||
// Get hash size info from adverts (has its own locking)
|
||||
hashInfo := s.GetNodeHashSizeInfo()
|
||||
|
||||
@@ -5882,24 +5893,21 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry {
|
||||
pubkey string
|
||||
prefix string
|
||||
}
|
||||
repeaterPrefixes := make(map[string][]prefixEntry) // prefix → entries
|
||||
for pk, n := range nodeByPK {
|
||||
if !strings.Contains(strings.ToLower(n.Role), "repeater") {
|
||||
continue
|
||||
}
|
||||
nodePrefixes := make(map[string][]prefixEntry) // prefix → entries
|
||||
for pk := range nodeByPK {
|
||||
// Generate 1-byte, 2-byte, 3-byte prefixes
|
||||
pkLower := strings.ToLower(pk)
|
||||
for byteLen := 1; byteLen <= 3; byteLen++ {
|
||||
hexLen := byteLen * 2
|
||||
if len(pkLower) >= hexLen {
|
||||
pfx := pkLower[:hexLen]
|
||||
repeaterPrefixes[pfx] = append(repeaterPrefixes[pfx], prefixEntry{pk, pfx})
|
||||
nodePrefixes[pfx] = append(nodePrefixes[pfx], prefixEntry{pk, pfx})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspected := make(map[string]int) // pubkey → max hash size from path appearances
|
||||
for pfx, entries := range repeaterPrefixes {
|
||||
for pfx, entries := range nodePrefixes {
|
||||
txList := s.byPathHop[pfx]
|
||||
for _, tx := range txList {
|
||||
if tx.RawHex == "" || len(tx.RawHex) < 4 {
|
||||
@@ -5945,9 +5953,9 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry {
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Build result for all repeaters — fetch last_seen from DB
|
||||
// Build result for all nodes — fetch last_seen from DB
|
||||
dbLastSeen := make(map[string]string)
|
||||
rows, err := s.db.conn.Query("SELECT public_key, last_seen FROM nodes WHERE role LIKE '%repeater%'")
|
||||
rows, err := s.db.conn.Query("SELECT public_key, last_seen FROM nodes")
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
@@ -5962,9 +5970,6 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry {
|
||||
|
||||
var result []MultiByteCapEntry
|
||||
for pk, n := range nodeByPK {
|
||||
if !strings.Contains(strings.ToLower(n.Role), "repeater") {
|
||||
continue
|
||||
}
|
||||
entry := MultiByteCapEntry{
|
||||
PublicKey: pk,
|
||||
Name: n.Name,
|
||||
@@ -5977,6 +5982,12 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry {
|
||||
entry.Status = "confirmed"
|
||||
entry.Evidence = "advert"
|
||||
entry.MaxHashSize = maxHS
|
||||
} else if maxHS, ok := adopterHashSizes[pk]; ok && maxHS >= 2 {
|
||||
// Adopter data (from computeAnalyticsHashSizes) shows hash_size >= 2
|
||||
// from advert analysis — this is advert-based evidence, so confirmed.
|
||||
entry.Status = "confirmed"
|
||||
entry.Evidence = "advert"
|
||||
entry.MaxHashSize = maxHS
|
||||
} else if maxHS, ok := suspected[pk]; ok {
|
||||
entry.Status = "suspected"
|
||||
entry.Evidence = "path"
|
||||
|
||||
@@ -1000,6 +1000,7 @@
|
||||
return (filtered.length ? '<table class="analytics-table" id="mbAdoptersTable" style="margin-top:12px">' +
|
||||
'<thead><tr>' +
|
||||
'<th scope="col" data-sort="name">Node</th>' +
|
||||
'<th scope="col" data-sort="role">Role</th>' +
|
||||
'<th scope="col" data-sort="status">Status</th>' +
|
||||
'<th scope="col" data-sort="hashSize">Hash Size</th>' +
|
||||
'<th scope="col" data-sort="packets">Adverts</th>' +
|
||||
@@ -1007,8 +1008,10 @@
|
||||
'</tr></thead>' +
|
||||
'<tbody>' +
|
||||
filtered.map(function(r) {
|
||||
var roleColor = (window.ROLE_COLORS || {})[r.role] || '#6b7280';
|
||||
return '<tr class="clickable-row" data-action="navigate" data-value="#/nodes/' + encodeURIComponent(r.pubkey) + '" tabindex="0" role="row">' +
|
||||
'<td><strong>' + esc(r.name) + '</strong></td>' +
|
||||
'<td><span class="badge" style="background:' + roleColor + '20;color:' + roleColor + '">' + esc(r.role || 'unknown') + '</span></td>' +
|
||||
'<td><span style="color:' + (statusColor[r.status] || statusColor.unknown) + '">' +
|
||||
(statusIcon[r.status] || '❓') + ' ' + (statusLabel[r.status] || 'Unknown') + '</span></td>' +
|
||||
'<td><span class="badge badge-hash-' + r.hashSize + '">' + r.hashSize + '-byte</span></td>' +
|
||||
|
||||
Reference in New Issue
Block a user