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 ? `
Heard by: ${preview.map(o => `${escapeHtml(obsFullName(o))}`).join(', ')}
` : ''} + ${cadenceHtml}
${sparkHtml}
@@ -461,6 +469,34 @@ dialog.showModal(); } + function renderMyMeshAdvertIntervals(intervals, backfill) { + const row = (label, estimate) => { + if (!estimate) return `
${label} unavailable
`; + 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 `
${label} ${value}${count}${seen}
`; + }; + const provisional = intervals && backfill?.status !== 'complete' + ? '
Route classes provisional
' : ''; + return `
` + + row('Zero-hop', intervals?.zero_hop) + row('Flood', intervals?.flood) + + provisional + '
Observed estimate ≠ setting · ≤20/type · missed receptions skew
'; + } + function buildSparkline(activity) { const label = '
Node-associated transmissions · last 24h
'; const buckets = activity?.buckets; @@ -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 || []; diff --git a/test-issue-2027-my-mesh-node-page-e2e.js b/test-issue-2027-my-mesh-node-page-e2e.js index 9211b0d60..a965b918a 100644 --- a/test-issue-2027-my-mesh-node-page-e2e.js +++ b/test-issue-2027-my-mesh-node-page-e2e.js @@ -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) { @@ -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'); diff --git a/test-issue-304-my-mesh-e2e.js b/test-issue-304-my-mesh-e2e.js index cccca3f78..9cd2c9e26 100644 --- a/test-issue-304-my-mesh-e2e.js +++ b/test-issue-304-my-mesh-e2e.js @@ -73,20 +73,31 @@ async function render(page, responses, width, height) { names[2] = ''; // Card roles are mixed (#304): a 51-observer repeater, a 1-observer // repeater, an observer-less repeater, a ≤3-observer repeater with a - // clipped name, and a NON-repeater with a clipped name. + // clipped name, and a NON-repeater with a clipped name. Repeaters 0–2 + // carry advert-interval estimates with pending, complete and missing + // route-mask backfill; repeater 3 has no estimate at all (#352). const specs = [ - { role: 'repeater', observers: names.map(observer_name => ({ observer_name })), activity: activity() }, - { role: 'repeater', observers: [{ observer_name: 'One' }], activity: activity(false) }, - { role: 'repeater', observers: [], activity: undefined }, + { role: 'repeater', observers: names.map(observer_name => ({ observer_name })), activity: activity(), + backfill: { status: 'pending', remaining: 15 }, + intervals: { + zero_hop: { status: 'estimated', interval_s: 7200, samples: 5, last_advert: end, confidence: 'medium' }, + flood: { status: 'too_few', interval_s: null, samples: 2, last_advert: end }, + } }, + { role: 'repeater', observers: [{ observer_name: 'One' }], activity: activity(false), + backfill: { status: 'complete', remaining: 0 }, + intervals: { zero_hop: { status: 'none_observed', samples: 0 }, flood: { status: 'irregular', samples: 5 } } }, + { role: 'repeater', observers: [], activity: undefined, + intervals: { zero_hop: { status: 'estimated', interval_s: 3600, samples: 4 }, flood: { status: 'too_few', samples: 1 } } }, { role: 'repeater', observers: [{ observer_name: CLIP }], activity: activity(false) }, { role: 'client', observers: [{ observer_name: CLIP }, { observer_name: 'Shorty' }], activity: activity(false) }, { role: 'repeater', observers: [{ observer_name: WIDE }], activity: activity(false) }, { role: 'repeater', observers: SHORT_WIDE.map(observer_name => ({ observer_name })), activity: activity(false) }, { role: 'client', observers: [{ observer_name: 'Shorty' }, ...SHORT_WIDE.map(observer_name => ({ observer_name }))], activity: activity(false) }, ]; - const data = Object.fromEntries(specs.map((spec, i) => [`/nodes/${KEYS[i]}/health`, { + const data = Object.fromEntries(specs.map((spec, i) => [`/nodes/${KEYS[i]}/health?include=advertIntervals`, { node: { name: `Node ${i}`, role: spec.role }, stats: { lastHeard: end, packetsToday: 2 }, observers: spec.observers, recentPackets: [], activity24h: spec.activity, + advertRouteBackfill: spec.backfill, advertIntervals: spec.intervals, }])); await render(page, data, width, height); if (process.env.SCREENSHOT_PATH && width === 1280) { @@ -109,6 +120,15 @@ async function render(page, responses, width, height) { assert.match(await cards.nth(1).locator('.mnc-spark').innerText(), /unavailable/i); assert.equal(await cards.nth(2).locator('.home-spark-bar').count(), 0); assert.match(await cards.nth(2).locator('.mnc-spark').innerText(), /unavailable/i); + assert.match(await large.locator('.mnc-advert-cadence').innerText(), /Zero-hop[\s\S]*≈ 2 h[\s\S]*Flood[\s\S]*not enough adverts/i); + assert.match(await large.locator('.mnc-advert-note').getAttribute('title'), /observed estimate.*not the configured timer/i); + assert.match(await large.locator('.mnc-advert-provisional').innerText(), /route classes provisional/i); + assert.match(await cards.nth(1).locator('.mnc-advert-cadence').innerText(), /not observed[\s\S]*irregular/i); + assert.equal(await cards.nth(1).locator('.mnc-advert-provisional').count(), 0); + assert.match(await cards.nth(2).locator('.mnc-advert-provisional').innerText(), /route classes provisional/i); + assert.match(await cards.nth(3).locator('.mnc-advert-cadence').innerText(), /unavailable/i); + assert.equal(await cards.nth(3).locator('.mnc-advert-provisional').count(), 0); + assert.equal(await cards.nth(4).locator('.mnc-advert-cadence').count(), 0, 'non-repeater card must not show advert intervals'); const heights = await cards.evaluateAll(els => els.map(el => el.getBoundingClientRect().height)); assert(heights[0] < 350, `repeater card too tall: ${heights[0]} at ${width}px`); const open = large.locator('.mnc-view-all'); @@ -191,6 +211,27 @@ async function render(page, responses, width, height) { assert.equal(attrs.filter(a => a.title === QUOTE).length, 1, 'quoted name must render verbatim'); assert.equal(await page.locator('[onmouseover]').count(), 0); + // #352: a card click opens health with the card's own opt-in URL, so + // the client cache answers it instead of a second request. The stub + // throws on any other route, which loadHealth shows as a failure. + await cards.nth(1).locator('.mnc-status-text').click(); + const panel = page.locator('#homeHealth'); + await page.waitForFunction(() => { const el = document.getElementById('homeHealth'); return el && !/Loading/.test(el.textContent); }); + assert.equal(await panel.locator('.health-banner').count(), 1, `card click must render health from the card's request: ${await panel.innerText()}`); + assert.match(await panel.locator('.health-banner').innerText(), /Node 1/); + // A node outside My Mesh keeps the plain URL: no advert scan for it. + const routes = await page.evaluate(async (key) => { + const seen = []; + const real = api; + api = async (route, opts) => { seen.push(route); return real(route, opts); }; + localStorage.setItem('meshcore-my-nodes', JSON.stringify(JSON.parse(localStorage.getItem('meshcore-my-nodes')).filter(n => n.pubkey !== key))); + document.querySelectorAll('.my-node-card')[1].querySelector('.mnc-status-text').click(); + await new Promise(r => setTimeout(r, 0)); + api = real; + return seen; + }, KEYS[1]); + assert.deepEqual(routes, [`/nodes/${KEYS[1]}/health`]); + assert.deepEqual(errors, []); await context.close(); }