mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-30 04:57:57 +00:00
feat: add hasScope/hashRegion filters to /api/nodes (closes upstream #1862)
?hasScope=true|false keeps only nodes that have/haven't ever transported a region-scoped packet; ?hashRegion=eu,be (comma-separated, leading # optional) keeps only nodes that have transported at least one of the given regions. Both combine with each other (AND) and with the existing ?role= filter, matching the suggested API shape from https://github.com/Kpa-clawbot/CoreScope/issues/1862. Implemented as two new predicates in the existing nodeListPostFilters (same post-SQL-LIMIT compensation-loop machinery geo_filter/blacklist/ area already use), backed by the same bulk relay-info map (GetRepeaterRelayInfoMap) the Scopes tab's "Repeaters Never Relaying Any Scope" section reads -- fetched once per request, not per DB page.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHandleNodes_HasScopeAndHashRegion covers issue #1862's suggested
|
||||
// filters: ?hasScope=true|false and ?hashRegion=#eu,#be on /api/nodes.
|
||||
// Both read TransportedScopes off the bulk relay-info map (same source as
|
||||
// the Scopes tab's "Repeaters Never Relaying Any Scope" section) rather
|
||||
// than any DB column, so the test seeds that cache directly instead of
|
||||
// reconstructing real path-hop parsing.
|
||||
func TestHandleNodes_HasScopeAndHashRegion(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
if _, err := srv.db.conn.Exec(`DELETE FROM nodes`); err != nil {
|
||||
t.Fatalf("clear nodes: %v", err)
|
||||
}
|
||||
|
||||
insert := func(pk, name, lastSeen string) {
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, ?, 'repeater', 55.0, 10.0, ?, '2026-06-01T00:00:00Z', 1)`,
|
||||
pk, name, lastSeen); err != nil {
|
||||
t.Fatalf("insert %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
insert("pk0000000000000a", "RelaysEU", "2026-06-07T00:00:00Z")
|
||||
insert("pk0000000000000b", "RelaysBoth", "2026-06-06T00:00:00Z")
|
||||
insert("pk0000000000000c", "NeverRelays", "2026-06-05T00:00:00Z")
|
||||
|
||||
// Seed the relay cache directly (same style as
|
||||
// TestGetRepeaterRelayInfoMap_ServesStaleOnTTLExpiry) -- lookups are by
|
||||
// lowercase pubkey.
|
||||
srv.store.repeaterRelayCache = map[string]RepeaterRelayInfo{
|
||||
"pk0000000000000a": {TransportedScopes: []string{"#eu"}},
|
||||
"pk0000000000000b": {TransportedScopes: []string{"#eu", "#be"}},
|
||||
// pk0000000000000c intentionally absent -- zero-value on lookup.
|
||||
}
|
||||
|
||||
fetchNames := func(query string) []string {
|
||||
req := httptest.NewRequest("GET", "/api/nodes?"+query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("query %q: status=%d body=%s", query, w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("query %q: decode: %v body=%s", query, err, w.Body.String())
|
||||
}
|
||||
names := make([]string, len(resp.Nodes))
|
||||
for i, n := range resp.Nodes {
|
||||
names[i], _ = n["name"].(string)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
assertNames := func(t *testing.T, query string, want ...string) {
|
||||
t.Helper()
|
||||
got := fetchNames(query)
|
||||
gotSet := map[string]bool{}
|
||||
for _, n := range got {
|
||||
gotSet[n] = true
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("query %q: got %v, want %v", query, got, want)
|
||||
return
|
||||
}
|
||||
for _, w := range want {
|
||||
if !gotSet[w] {
|
||||
t.Errorf("query %q: got %v, missing %q", query, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("hasScope=true", func(t *testing.T) {
|
||||
assertNames(t, "hasScope=true", "RelaysEU", "RelaysBoth")
|
||||
})
|
||||
t.Run("hasScope=false", func(t *testing.T) {
|
||||
assertNames(t, "hasScope=false", "NeverRelays")
|
||||
})
|
||||
t.Run("hashRegion single, with # and case-insensitive", func(t *testing.T) {
|
||||
assertNames(t, fmt.Sprintf("hashRegion=%s", "%23EU"), "RelaysEU", "RelaysBoth")
|
||||
})
|
||||
t.Run("hashRegion comma-separated OR match", func(t *testing.T) {
|
||||
assertNames(t, "hashRegion=be", "RelaysBoth")
|
||||
})
|
||||
t.Run("hasScope and hashRegion combine (AND)", func(t *testing.T) {
|
||||
assertNames(t, "hasScope=true&hashRegion=be", "RelaysBoth")
|
||||
})
|
||||
t.Run("no filter returns all", func(t *testing.T) {
|
||||
assertNames(t, "", "RelaysEU", "RelaysBoth", "NeverRelays")
|
||||
})
|
||||
}
|
||||
@@ -81,6 +81,8 @@ func routeDescriptions() map[string]routeMeta {
|
||||
{Name: "role", Description: "Filter by node role", Type: "string"},
|
||||
{Name: "status", Description: "Filter by status (active/stale/offline)", Type: "string"},
|
||||
{Name: "geoFilter", Description: "Overrides the deployment's geo_filter node-list default for this one request: \"1\"/\"true\" excludes nodes outside the configured geo_filter (unless foreign_advert-tagged), \"0\"/\"false\" returns every node regardless. Any other value (including omitting it) uses the deployment default — geo_filter applies to the node list unless config.json sets geoFilterExemptNodeList=true.", Type: "string"},
|
||||
{Name: "hasScope", Description: "Issue #1862. \"true\" keeps only nodes that have ever transported at least one region-scoped (TRANSPORT_FLOOD/DIRECT) packet; \"false\" keeps only nodes that never have. Backed by the same relay-activity signal as the Scopes tab's \"Repeaters Never Relaying Any Scope\" section — pair with role=repeater to match its exact semantics.", Type: "string"},
|
||||
{Name: "hashRegion", Description: "Issue #1862. Comma-separated region scope name(s) (e.g. \"eu,be\"; leading \"#\" optional, case-insensitive) — keeps only nodes that have transported at least one of them. Combines with hasScope as AND, not OR.", Type: "string"},
|
||||
}},
|
||||
"GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}},
|
||||
"GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"},
|
||||
|
||||
+76
-5
@@ -1364,17 +1364,43 @@ func (s *Server) handlePostPacket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- Node Handlers ---
|
||||
|
||||
// nodeListPostFilters bundles the four filters handleNodes applies AFTER
|
||||
// nodeListPostFilters bundles the filters handleNodes applies AFTER
|
||||
// the SQL LIMIT/OFFSET page comes back from the DB: geo_filter (#730),
|
||||
// node blacklist, hidden-name-prefix (#1181), and area membership. Kept
|
||||
// together so handleNodes' pagination-compensation loop (see below) can
|
||||
// apply the exact same predicate to every DB page it pulls.
|
||||
// node blacklist, hidden-name-prefix (#1181), area membership, and (#1862)
|
||||
// hasScope/hashRegions relay filters. Kept together so handleNodes'
|
||||
// pagination-compensation loop (see below) can apply the exact same
|
||||
// predicate to every DB page it pulls.
|
||||
type nodeListPostFilters struct {
|
||||
cfg *Config
|
||||
geoFilter *GeoFilterConfig // resolved once via s.getGeoFilter() — cfgMu guards this field, see routes.go:53
|
||||
applyGeo bool
|
||||
areaNodes map[string]bool // nil = no area filter active
|
||||
areaRequested bool // true if ?area= was given but resolved to nothing
|
||||
|
||||
// relayMap backs hasScope/hashRegions below — the bulk pubkey ->
|
||||
// RepeaterRelayInfo map (see GetRepeaterRelayInfoMap), keyed by
|
||||
// lowercase pubkey. nil when the in-memory store is unavailable (DB-
|
||||
// only mode): lookups then degrade to a zero-value RepeaterRelayInfo
|
||||
// (no known transported scopes), same as any pubkey missing from the map.
|
||||
relayMap map[string]RepeaterRelayInfo
|
||||
// hasScope implements ?hasScope=true|false (#1862): nil = not
|
||||
// requested, otherwise a node must have (true) or must not have
|
||||
// (false) at least one entry in TransportedScopes to match.
|
||||
hasScope *bool
|
||||
// hashRegions implements ?hashRegion=#eu,#be (#1862): normalized
|
||||
// (lowercased, "#" stripped) region names, OR-matched against a
|
||||
// node's TransportedScopes. nil/empty = not requested.
|
||||
hashRegions map[string]bool
|
||||
}
|
||||
|
||||
// relayInfoFor looks up f.relayMap by lowercase pubkey, returning a
|
||||
// zero-value RepeaterRelayInfo (empty TransportedScopes) for a pubkey with
|
||||
// no relay activity or when relayMap itself is nil.
|
||||
func (f nodeListPostFilters) relayInfoFor(pk string) RepeaterRelayInfo {
|
||||
if f.relayMap == nil {
|
||||
return RepeaterRelayInfo{}
|
||||
}
|
||||
return f.relayMap[strings.ToLower(pk)]
|
||||
}
|
||||
|
||||
func (f nodeListPostFilters) apply(nodes []map[string]interface{}) []map[string]interface{} {
|
||||
@@ -1410,6 +1436,25 @@ func (f nodeListPostFilters) apply(nodes []map[string]interface{}) []map[string]
|
||||
continue
|
||||
}
|
||||
}
|
||||
if f.hasScope != nil || len(f.hashRegions) > 0 {
|
||||
pk, _ := node["public_key"].(string)
|
||||
info := f.relayInfoFor(pk)
|
||||
if f.hasScope != nil && (len(info.TransportedScopes) > 0) != *f.hasScope {
|
||||
continue
|
||||
}
|
||||
if len(f.hashRegions) > 0 {
|
||||
matched := false
|
||||
for _, rs := range info.TransportedScopes {
|
||||
if f.hashRegions[strings.ToLower(strings.TrimPrefix(rs, "#"))] {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, node)
|
||||
}
|
||||
return out
|
||||
@@ -1419,7 +1464,8 @@ func (f nodeListPostFilters) apply(nodes []map[string]interface{}) []map[string]
|
||||
// to skip the compensation loop entirely on the (common) unfiltered path.
|
||||
func (f nodeListPostFilters) active() bool {
|
||||
return (f.geoFilter != nil && f.applyGeo) || len(f.cfg.NodeBlacklist) > 0 ||
|
||||
len(f.cfg.HiddenNamePrefixes) > 0 || f.areaRequested
|
||||
len(f.cfg.HiddenNamePrefixes) > 0 || f.areaRequested ||
|
||||
f.hasScope != nil || len(f.hashRegions) > 0
|
||||
}
|
||||
|
||||
func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1452,6 +1498,31 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
filters := nodeListPostFilters{cfg: s.cfg, geoFilter: s.getGeoFilter(), applyGeo: applyGeoFilter}
|
||||
|
||||
// #1862: ?hasScope=true|false and ?hashRegion=#eu,#be filter the node
|
||||
// list by relay activity — which repeaters have (or haven't) ever
|
||||
// transported a region-scoped packet, and specifically which
|
||||
// region(s). Both read the same bulk relay-info map TransportedScopes
|
||||
// already backs elsewhere (Scopes tab's "Repeaters Never Relaying Any
|
||||
// Scope"), fetched once here (not per-page) since it's independent of
|
||||
// pagination.
|
||||
if hs := q.Get("hasScope"); hs == "true" || hs == "false" {
|
||||
v := hs == "true"
|
||||
filters.hasScope = &v
|
||||
}
|
||||
if hr := q.Get("hashRegion"); hr != "" {
|
||||
filters.hashRegions = make(map[string]bool)
|
||||
for _, part := range strings.Split(hr, ",") {
|
||||
part = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "#")))
|
||||
if part != "" {
|
||||
filters.hashRegions[part] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.store != nil && (filters.hasScope != nil || len(filters.hashRegions) > 0) {
|
||||
filters.relayMap = s.store.GetRepeaterRelayInfoMap(s.cfg.GetHealthThresholds().RelayActiveHours)
|
||||
}
|
||||
|
||||
if area := q.Get("area"); area != "" {
|
||||
filters.areaRequested = true
|
||||
if s.store != nil {
|
||||
|
||||
Reference in New Issue
Block a user