Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/server/node_advert_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
115 changes: 115 additions & 0 deletions cmd/server/node_health_advert_intervals_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
15 changes: 15 additions & 0 deletions cmd/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
16 changes: 16 additions & 0 deletions docs/api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions public/home.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
40 changes: 38 additions & 2 deletions public/home.js
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down Expand Up @@ -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 || [];
Expand All @@ -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),
Expand Down Expand Up @@ -357,6 +364,7 @@
</div>
</div>
${obs.length ? `<div class="mnc-observers"><strong>Heard by:</strong> ${preview.map(o => `<span class="mnc-observer-name" title="${escapeAttr(obsFullName(o))}">${escapeHtml(obsFullName(o))}</span>`).join(', ')} <button type="button" class="mnc-btn mnc-view-all" data-action="observers" data-key="${escapeAttr(mn.pubkey)}" aria-label="Show all ${obs.length} observer names for ${escapeAttr(name)}"${hiddenObservers > 0 ? '' : ' hidden'}>${observerBtnText}</button></div>` : ''}
${cadenceHtml}
<div class="mnc-spark">${sparkHtml}</div>
<div class="mnc-actions">
<button class="mnc-btn" data-action="node" data-key="${escapeAttr(mn.pubkey)}">Node page →</button>
Expand Down Expand Up @@ -461,6 +469,34 @@
dialog.showModal();
}

function renderMyMeshAdvertIntervals(intervals, backfill) {
const row = (label, estimate) => {
if (!estimate) return `<div class="mnc-advert-row"><strong>${label}</strong> unavailable</div>`;
const samples = Number(estimate.samples);
const seconds = Number(estimate.interval_s);
let value = 'unavailable';
if (estimate.status === 'estimated' && estimate.interval_s != null && Number.isFinite(seconds) && seconds > 0) {
const hours = seconds / 3600;
value = '≈ ' + (hours >= 1 ? Number(hours.toFixed(1)) + ' h' : Math.round(seconds / 60) + ' min');
} else if (estimate.status === 'too_few') {
value = 'not enough adverts';
} else if (estimate.status === 'none_observed') {
value = 'not observed';
} else if (estimate.status === 'irregular') {
value = 'irregular';
}
const count = Number.isInteger(samples) && samples >= 0 ? ` · ${samples} heard` : '';
const seen = estimate.last_advert && Number.isFinite(Date.parse(estimate.last_advert))
? ' · last ' + timeAgo(estimate.last_advert) : '';
return `<div class="mnc-advert-row"><strong>${label}</strong> ${value}${count}${seen}</div>`;
};
const provisional = intervals && backfill?.status !== 'complete'
? '<div class="mnc-advert-provisional" title="Route-mask backfill incomplete or unknown; flood and zero-hop classifications may change">Route classes provisional</div>' : '';
return `<div class="mnc-advert-cadence" aria-label="Observed repeater advert intervals">` +
row('Zero-hop', intervals?.zero_hop) + row('Flood', intervals?.flood) +
provisional + '<div class="mnc-advert-note" title="Observed estimate from at most 20 newest adverts per type; missed receptions can skew it. Not the configured timer.">Observed estimate ≠ setting · ≤20/type · missed receptions skew</div></div>';
}

function buildSparkline(activity) {
const label = '<div class="home-spark-label">Node-associated transmissions · last 24h</div>';
const buckets = activity?.buckets;
Expand Down Expand Up @@ -502,7 +538,7 @@
if (journey) journey.classList.remove('visible');

try {
const h = await api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth });
const h = await api(healthPath(pubkey, isMyNode(pubkey)), { ttl: CLIENT_TTL.nodeHealth });
const node = h.node || {};
const stats = h.stats || {};
const packets = h.recentPackets || [];
Expand Down
15 changes: 12 additions & 3 deletions test-issue-2027-my-mesh-node-page-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,19 @@ async function newHarness(browser) {
}

function stub(page, path_, data) {
return page.evaluate(({ p, d }) => { window.__apiResponders[p] = { data: d }; }, { p: path_, d: data });
return page.evaluate(({ p, d }) => {
window.__apiResponders[p] = { data: d };
// My Mesh cards opt into advert intervals on their existing health
// request, and Full health reuses that URL for claimed nodes. Nodes
// outside My Mesh fetch the unqualified URL.
if (p.endsWith('/health')) window.__apiResponders[p + '?include=advertIntervals'] = { data: d };
}, { p: path_, d: data });
}
function stubError(page, path_, message) {
return page.evaluate(({ p, m }) => { window.__apiResponders[p] = { error: new Error(m) }; }, { p: path_, m: message });
return page.evaluate(({ p, m }) => {
window.__apiResponders[p] = { error: new Error(m) };
if (p.endsWith('/health')) window.__apiResponders[p + '?include=advertIntervals'] = { error: new Error(m) };
}, { p: path_, m: message });
}

async function renderMyMesh(page, myNodes) {
Expand Down Expand Up @@ -384,7 +393,7 @@ function cardFor(page, pubkey) {
// (or a leaked card click) has already recorded its calls by the time
// the re-render lands.
const added = await page.evaluate((n) => window.__apiCalls.slice(n), before);
assert.deepEqual(added, ['/nodes/' + encodeURIComponent(ERROR_PK) + '/health'],
assert.deepEqual(added, ['/nodes/' + encodeURIComponent(ERROR_PK) + '/health?include=advertIntervals'],
'exactly one re-render of the remaining node and no health fetch for the removed one');
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]').map((n) => n.pubkey));
assert.deepEqual(stored, [ERROR_PK], 'only the focused node is removed from My Mesh');
Expand Down
Loading
Loading