diff --git a/cmd/ingestor/coverage_boost_test.go b/cmd/ingestor/coverage_boost_test.go index fa7315c0..9b093cff 100644 --- a/cmd/ingestor/coverage_boost_test.go +++ b/cmd/ingestor/coverage_boost_test.go @@ -203,21 +203,13 @@ func TestHandleMessageChannelMessage(t *testing.T) { t.Errorf("direction=%v, want rx", direction) } - // Should create sender node + // Sender node should NOT be created (see issue #665: synthetic "sender-" keys + // are unreachable from the claiming/health flow) if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil { t.Fatal(err) } - if count != 1 { - t.Errorf("nodes count=%d, want 1 (sender node)", count) - } - - // Verify sender node name - var nodeName string - if err := store.db.QueryRow("SELECT name FROM nodes LIMIT 1").Scan(&nodeName); err != nil { - t.Fatal(err) - } - if nodeName != "Alice" { - t.Errorf("node name=%s, want Alice", nodeName) + if count != 0 { + t.Errorf("nodes count=%d, want 0 (no phantom sender node)", count) } } diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index 0beb3ec2..4de850fc 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -446,13 +446,11 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, log.Printf("MQTT [%s] channel insert error: %v", tag, err) } - // Upsert sender as a companion node - if sender != "" { - senderKey := "sender-" + strings.ToLower(sender) - if err := store.UpsertNode(senderKey, sender, "companion", nil, nil, now); err != nil { - log.Printf("MQTT [%s] sender node upsert error: %v", tag, err) - } - } + // Note: we intentionally do NOT create a node entry for channel message senders. + // Channel messages don't carry the sender's real pubkey, so any entry we create + // would use a synthetic key ("sender-") that doesn't match the real pubkey + // used for claiming/health lookups. The node will get a proper entry when it + // sends an advert. See issue #665. log.Printf("MQTT [%s] channel message: ch%s from %s", tag, channelIdx, firstNonEmpty(sender, "unknown")) return diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index d18c4d7e..da9724dd 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -774,6 +774,67 @@ func TestNodeHealthNotFound(t *testing.T) { } } +// TestNodeHealthPartialFromPackets verifies that a node with packets in the +// in-memory store but no DB entry returns a partial 200 response instead of 404. +// This is the fix for issue #665 (companion nodes without adverts). +func TestNodeHealthPartialFromPackets(t *testing.T) { + srv, router := setupTestServer(t) + + // Inject a packet into byNode for a pubkey that doesn't exist in the nodes table + ghostPubkey := "ghost_companion_no_advert" + now := time.Now().UTC().Format(time.RFC3339) + snr := 5.0 + srv.store.mu.Lock() + if srv.store.byNode == nil { + srv.store.byNode = make(map[string][]*StoreTx) + } + if srv.store.nodeHashes == nil { + srv.store.nodeHashes = make(map[string]map[string]bool) + } + srv.store.byNode[ghostPubkey] = []*StoreTx{ + {Hash: "abc123", FirstSeen: now, SNR: &snr, ObservationCount: 1}, + } + srv.store.nodeHashes[ghostPubkey] = map[string]bool{"abc123": true} + srv.store.mu.Unlock() + + req := httptest.NewRequest("GET", "/api/nodes/"+ghostPubkey+"/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200 for ghost companion, got %d (body: %s)", w.Code, w.Body.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("json unmarshal: %v", err) + } + + // Should have a synthetic node stub + node, ok := body["node"].(map[string]interface{}) + if !ok || node == nil { + t.Fatal("expected node in response") + } + if node["role"] != "unknown" { + t.Errorf("expected role=unknown, got %v", node["role"]) + } + if node["public_key"] != ghostPubkey { + t.Errorf("expected public_key=%s, got %v", ghostPubkey, node["public_key"]) + } + + // Should have stats from the packet + stats, ok := body["stats"].(map[string]interface{}) + if !ok || stats == nil { + t.Fatal("expected stats in response") + } + if stats["totalPackets"] != 1.0 { // JSON numbers are float64 + t.Errorf("expected totalPackets=1, got %v", stats["totalPackets"]) + } + if stats["lastHeard"] == nil { + t.Error("expected lastHeard to be set") + } +} + func TestBulkHealthEndpoint(t *testing.T) { _, router := setupTestServer(t) req := httptest.NewRequest("GET", "/api/nodes/bulk-health?limit=10", nil) diff --git a/cmd/server/store.go b/cmd/server/store.go index 973625b3..5896dd85 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -5629,9 +5629,25 @@ func (s *PacketStore) GetBulkHealth(limit int, region string) []map[string]inter func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, error) { // Fetch node info from DB (fast single-row lookup) node, err := s.db.GetNodeByPubkey(pubkey) - if err != nil || node == nil { + if err != nil { return nil, err } + // If the node isn't in the DB (e.g. companion that never advertised), + // check if we have any packet data for it. If so, build a partial response. + if node == nil { + s.mu.RLock() + hasPackets := len(s.byNode[pubkey]) > 0 + s.mu.RUnlock() + if !hasPackets { + return nil, nil + } + // Build a synthetic node stub so the rest of the function works + node = map[string]interface{}{ + "public_key": pubkey, + "name": "Unknown", + "role": "unknown", + } + } s.mu.RLock() defer s.mu.RUnlock() diff --git a/public/home.js b/public/home.js index 4b1018e6..69ff2ffd 100644 --- a/public/home.js +++ b/public/home.js @@ -302,14 +302,19 @@ `; - } catch { + } catch (err) { + const is404 = err && err.message && err.message.includes('404'); + const statusIcon = is404 ? '📡' : '❓'; + const statusMsg = is404 + ? 'Waiting for first advert — this node has been seen in channel messages but hasn\u2019t advertised yet' + : 'Could not load data'; return `
-
+
${statusIcon}
${escapeHtml(mn.name || truncate(mn.pubkey, 12))}
-
Could not load data
+
${statusMsg}
`; } }));