diff --git a/cmd/server/node_advert_routes.go b/cmd/server/node_advert_routes.go index c65ed6805..93e43e9f6 100644 --- a/cmd/server/node_advert_routes.go +++ b/cmd/server/node_advert_routes.go @@ -54,9 +54,14 @@ const nodeDetailIncludeAdvertRoutes = "advertRoutes" // include=advertRoutes (the include parameter may repeat and holds a // comma-separated list). func wantsNodeAdvertRoutes(r *http.Request) bool { + return wantsNodeInclude(r, nodeDetailIncludeAdvertRoutes) +} + +// wantsNodeInclude accepts repeated and comma-separated include values. +func wantsNodeInclude(r *http.Request, name string) bool { for _, v := range r.URL.Query()["include"] { for _, item := range strings.Split(v, ",") { - if strings.TrimSpace(item) == nodeDetailIncludeAdvertRoutes { + if strings.TrimSpace(item) == name { return true } } diff --git a/cmd/server/node_health_advert_intervals_test.go b/cmd/server/node_health_advert_intervals_test.go new file mode 100644 index 000000000..c4714646a --- /dev/null +++ b/cmd/server/node_health_advert_intervals_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestNodeHealthAdvertIntervalsOptInRoleAndPrivacy(t *testing.T) { + srv, router := setupTestServer(t) + const repeater = "abcdabcdabcdabcd" + const companion = "bcdabcdabcdabcda" + for _, n := range []struct{ key, role string }{{repeater, "repeater"}, {companion, "companion"}} { + _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key,name,role,lat,lon,last_seen,first_seen,advert_count) VALUES (?, ?, ?, 0,0,'2026-10-07T00:00:00Z','2026-10-07T00:00:00Z',1)`, n.key, n.role, n.role) + if err != nil { + t.Fatal(err) + } + } + lookups, scans := narCountRouteWork(srv) + get := func(key, query string) map[string]json.RawMessage { + t.Helper() + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/nodes/"+key+"/health"+query, nil)) + if w.Code != 200 { + t.Fatalf("health %s %s: %d %s", key, query, w.Code, w.Body.String()) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body + } + if _, ok := get(repeater, "")["advertIntervals"]; ok { + t.Fatal("default response added advertIntervals") + } + if _, ok := get(repeater, "")["advertRouteBackfill"]; ok { + t.Fatal("default response added advertRouteBackfill") + } + if _, ok := get(companion, "?include=advertIntervals")["advertIntervals"]; ok { + t.Fatal("non-repeater exposes intervals") + } + if _, ok := get(companion, "?include=advertIntervals")["advertRouteBackfill"]; ok { + t.Fatal("non-repeater exposes route backfill") + } + if lookups.Load() != 0 { + t.Fatalf("unexpected route lookups: %d", lookups.Load()) + } + body := get(repeater, "?include=advertIntervals") + if _, ok := body["advertIntervals"]; !ok { + t.Fatal("repeater missing intervals") + } + var backfill RouteMaskBackfillStatus + if err := json.Unmarshal(body["advertRouteBackfill"], &backfill); err != nil || backfill.Status == "" { + t.Fatalf("missing typed backfill status: %v, body=%s", err, body["advertRouteBackfill"]) + } + if lookups.Load() != 1 { + t.Fatalf("want one cache lookup, got %d", lookups.Load()) + } + if _, ok := get(repeater, "?include=other,advertIntervals")["advertIntervals"]; !ok { + t.Fatal("comma include missing intervals") + } + if _, ok := get(repeater, "?include=other&include=advertIntervals")["advertIntervals"]; !ok { + t.Fatal("repeated include missing intervals") + } + if lookups.Load() != 3 { + t.Fatalf("want three cache lookups, got %d", lookups.Load()) + } + if scans.Load() != 1 { + t.Fatalf("opt-in health requests should reuse one cached scan, got %d", scans.Load()) + } + for _, status := range []string{"pending", "complete"} { + srv.db.routeMaskStatusMu.Lock() + srv.db.routeMaskStatus = RouteMaskBackfillStatus{Status: status} + srv.db.routeMaskStatusExp = time.Now().Add(time.Minute) + srv.db.routeMaskStatusMu.Unlock() + body := get(repeater, "?include=advertIntervals") + var got RouteMaskBackfillStatus + if err := json.Unmarshal(body["advertRouteBackfill"], &got); err != nil || got.Status != status { + t.Fatalf("status=%s, wire backfill=%+v err=%v", status, got, err) + } + } + srv.cfg.ObserverBlacklist = []string{strings.ToUpper(repeater)} + if _, ok := get(repeater, "?include=advertIntervals")["advertIntervals"]; ok { + t.Fatal("hidden identity exposed intervals") + } + if _, ok := get(repeater, "?include=advertIntervals")["advertRouteBackfill"]; ok { + t.Fatal("hidden identity exposed backfill") + } + if lookups.Load() != 5 { + t.Fatal("privacy must gate before cache") + } +} + +func TestNodeHealthAdvertIntervalsFailsClosedOnIdentityLookupError(t *testing.T) { + srv, router := setupTestServer(t) + const key = "abcdabcdabcdabcd" + if _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key,name,role,lat,lon,last_seen,first_seen,advert_count) VALUES (?, 'repeater', 'repeater',0,0,'2026-10-07T00:00:00Z','2026-10-07T00:00:00Z',1)`, key); err != nil { + t.Fatal(err) + } + srv.cfg.SetHiddenNamePrefixes([]string{"ZZ-unmatched-prefix"}) + lookups, _ := narCountRouteWork(srv) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/nodes/"+key+"/health?include=advertIntervals", nil).WithContext(ctx)) + if w.Code != 200 { + t.Fatalf("health status %d: %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "advertIntervals") || strings.Contains(w.Body.String(), "advertRouteBackfill") || lookups.Load() != 0 { + t.Fatalf("failed identity lookup must not expose cadence or consult cache: %s", w.Body.String()) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 508c11c76..ae6d9cd9a 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2116,6 +2116,21 @@ func (s *Server) handleNodeHealth(w http.ResponseWriter, r *http.Request) { writeError(w, 404, "Not found") return } + // My Mesh opts into the existing node-detail estimator on the same + // health request. Keep the expensive route scan/cache out of default + // health calls and non-repeater cards, and apply its privacy gate first. + if wantsNodeInclude(r, "advertIntervals") { + node, _ := result["node"].(map[string]interface{}) + if role, _ := node["role"].(string); strings.EqualFold(role, "repeater") { + hidden, hideErr := s.isIdentityHidden(r.Context(), pubkey) + if hideErr == nil && !hidden { + if adverts, advertErr := s.nodeAdvertRoutes(pubkey, time.Now()); advertErr == nil { + result["advertIntervals"] = adverts.intervals + result["advertRouteBackfill"] = adverts.counts.RouteMaskBackfill + } + } + } + } writeJSON(w, result) return } diff --git a/docs/api-spec.md b/docs/api-spec.md index 4269a9c7f..cb7c20653 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -813,6 +813,22 @@ buckets before `coverageStart` represent unknown history rather than confirmed silence. A null `coverageStart` means coverage cannot be established (for example during startup or after a failed load). +With `?include=advertIntervals`, repeater health may also return +`advertIntervals` in the same shape as the node-detail `include=advertRoutes` +response: `{ "window": 20, "flood": AdvertIntervalEstimate, +"zero_hop": AdvertIntervalEstimate }`, plus `advertRouteBackfill` as a +`RouteMaskBackfillStatus` (`status`: `pending`, `backfilling`, or `complete`; +`remaining`: number or null). Both fields are absent by default, for +non-repeaters, and for hidden identities. If the backfill status is anything +other than `complete`, including absent in an older response, route classes +and their estimated intervals are provisional. The server reuses the bounded, +per-node cached advert-route scan; this is an opt-in because a cold scan of a +node with many adverts is comparatively expensive. `interval_s` is an +**estimate** from at most the newest 20 distinct adverts in each route class, +not the repeater's configured interval. Missed receptions and limited history +can skew it. `null` plus `status` (`none_observed`, `too_few`, or `irregular`) +means no defensible estimate, not proof the device's timer is disabled. + ### Response `404` ```json diff --git a/public/home.css b/public/home.css index 86b95d7c5..63349b9c5 100644 --- a/public/home.css +++ b/public/home.css @@ -487,6 +487,12 @@ .mnc-dialog-list { max-height: 50vh; overflow: auto; margin: 0; padding-left: 24px; } .mnc-dialog-list li { overflow-wrap: anywhere; margin-bottom: 5px; } +.mnc-advert-cadence { margin: 0 0 10px; font-size: .75rem; line-height: 1.35; } +.mnc-advert-row { display: flex; flex-wrap: wrap; gap: .35em; } +.mnc-advert-row strong { min-width: 5.5em; } +.mnc-advert-provisional { color: var(--status-yellow); font-size: .7rem; } +.mnc-advert-note { color: var(--text-muted); font-size: .68rem; } + .mnc-spark { margin-bottom: 8px; } .home-spark-unavailable { font-size: .7rem; color: var(--text-muted); } .spark-label { font-size: .65rem; color: var(--text-muted); margin-bottom: 4px; } diff --git a/public/home.js b/public/home.js index 3c6269f3b..bc75c892d 100644 --- a/public/home.js +++ b/public/home.js @@ -45,6 +45,12 @@ saveMyNodes(getMyNodes().filter(n => n.pubkey !== pubkey)); } function isMyNode(pubkey) { return getMyNodes().some(n => n.pubkey === pubkey); } + // My Mesh cards opt into advert intervals on their health request. The + // health panel reuses that URL for claimed nodes so the client cache + // answers it; other nodes keep the plain URL and skip the advert scan. + function healthPath(pubkey, myMesh) { + return '/nodes/' + encodeURIComponent(pubkey) + '/health' + (myMesh ? '?include=advertIntervals' : ''); + } function isExperienced() { return localStorage.getItem(PREF_KEY) === 'experienced'; } function setLevel(level) { localStorage.setItem(PREF_KEY, level); } @@ -298,7 +304,7 @@ const cards = await Promise.all(myNodes.map(async (mn) => { try { - const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth }); + const h = await api(healthPath(mn.pubkey, true), { ttl: CLIENT_TTL.nodeHealth }); const node = h.node || {}; const stats = h.stats || {}; const obs = h.observers || []; @@ -319,6 +325,7 @@ const sparkHtml = buildSparkline(h.activity24h); const isRepeater = String(node.role || '').toLowerCase() === 'repeater'; const preview = isRepeater ? obs.slice(0, 3) : obs; + const cadenceHtml = isRepeater ? renderMyMeshAdvertIntervals(h.advertIntervals, h.advertRouteBackfill) : ''; // #351 F2/R1: a name wider than the CSS max-width: 14ch clamp is // clipped with an ellipsis. The full name must stay recoverable on // EVERY card. Each span carries a title (pointer + assistive tech), @@ -357,6 +364,7 @@ ${obs.length ? `