From ade8027245efa0e4fcc17a8661928161e36e55a2 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 14:03:49 +0200 Subject: [PATCH 1/4] Show repeater advert interval estimates on My Mesh cards --- cmd/server/node_advert_routes.go | 7 +- .../node_health_advert_intervals_test.go | 89 +++++++++++++++++++ cmd/server/routes.go | 14 +++ docs/api-spec.md | 12 +++ public/home.css | 5 ++ public/home.js | 30 ++++++- test-issue-2027-my-mesh-node-page-e2e.js | 14 ++- test-issue-304-my-mesh-e2e.js | 10 ++- 8 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 cmd/server/node_health_advert_intervals_test.go 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..e311fe826 --- /dev/null +++ b/cmd/server/node_health_advert_intervals_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" +) + +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(companion, "?include=advertIntervals")["advertIntervals"]; ok { + t.Fatal("non-repeater exposes intervals") + } + if lookups.Load() != 0 { + t.Fatalf("unexpected route lookups: %d", lookups.Load()) + } + if _, ok := get(repeater, "?include=advertIntervals")["advertIntervals"]; !ok { + t.Fatal("repeater missing intervals") + } + 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()) + } + srv.cfg.ObserverBlacklist = []string{strings.ToUpper(repeater)} + if _, ok := get(repeater, "?include=advertIntervals")["advertIntervals"]; ok { + t.Fatal("hidden identity exposed intervals") + } + if lookups.Load() != 3 { + 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") || 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 111439f94..755f7c287 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2107,6 +2107,20 @@ 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 + } + } + } + } writeJSON(w, result) return } diff --git a/docs/api-spec.md b/docs/api-spec.md index 56f8adbb8..044dba4e6 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -813,6 +813,18 @@ 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 }`. The field is absent by default, for +non-repeaters, and for hidden identities. 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..1574eba31 100644 --- a/public/home.css +++ b/public/home.css @@ -487,6 +487,11 @@ .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-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 dc5546c2b..7854b3638 100644 --- a/public/home.js +++ b/public/home.js @@ -298,7 +298,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('/nodes/' + encodeURIComponent(mn.pubkey) + '/health?include=advertIntervals', { ttl: CLIENT_TTL.nodeHealth }); const node = h.node || {}; const stats = h.stats || {}; const obs = h.observers || []; @@ -319,6 +319,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) : ''; return `
@@ -347,6 +348,7 @@
${obs.length ? `
Heard by: ${preview.map(o => `${escapeHtml(o.observer_name || o.observer_id || 'Unknown')}`).join(', ')}${isRepeater && obs.length > 3 ? ` ` : ''}
` : ''} + ${cadenceHtml}
${sparkHtml}
@@ -441,6 +443,32 @@ dialog.showModal(); } + function renderMyMeshAdvertIntervals(intervals) { + 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}
`; + }; + return `
` + + row('Zero-hop', intervals?.zero_hop) + row('Flood', intervals?.flood) + + '
Observed estimate, not configured interval · newest 20 adverts per type; missed receptions can skew it · see Node page
'; + } + function buildSparkline(activity) { const label = '
Node-associated transmissions · last 24h
'; const buckets = activity?.buckets; diff --git a/test-issue-2027-my-mesh-node-page-e2e.js b/test-issue-2027-my-mesh-node-page-e2e.js index 9211b0d60..eb1d1636b 100644 --- a/test-issue-2027-my-mesh-node-page-e2e.js +++ b/test-issue-2027-my-mesh-node-page-e2e.js @@ -152,10 +152,18 @@ 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; Full health still fetches the original 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 +392,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 cba798866..51c0e095b 100644 --- a/test-issue-304-my-mesh-e2e.js +++ b/test-issue-304-my-mesh-e2e.js @@ -60,10 +60,14 @@ async function render(page, responses, width) { const names = Array.from({ length: 51 }, (_, i) => i === 50 ? '' : `Observer ${i}`); names[1] = 'Very-long-observer-name-'.repeat(25); names[2] = ''; - const data = Object.fromEntries(KEYS.map((key, i) => [`/nodes/${key}/health`, { + const data = Object.fromEntries(KEYS.map((key, i) => [`/nodes/${key}/health?include=advertIntervals`, { node: { name: `Repeater ${i}`, role: 'repeater' }, stats: { lastHeard: end, packetsToday: 2 }, observers: i === 0 ? names.map(observer_name => ({ observer_name })) : i === 1 ? [{ observer_name: 'One' }] : [], recentPackets: [], activity24h: i === 0 ? activity() : i === 1 ? activity(false) : undefined, + advertIntervals: i === 0 ? { + 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 }, + } : i === 1 ? { zero_hop: { status: 'none_observed', samples: 0 }, flood: { status: 'irregular', samples: 5 } } : undefined, }])); await render(page, data, width); if (process.env.SCREENSHOT_PATH && width === 1280) { @@ -85,6 +89,10 @@ async function render(page, responses, width) { 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-cadence').innerText(), /observed estimate.*not configured/i); + assert.match(await cards.nth(1).locator('.mnc-advert-cadence').innerText(), /not observed[\s\S]*irregular/i); + assert.match(await cards.nth(2).locator('.mnc-advert-cadence').innerText(), /unavailable/i); 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'); From 1f54e5a485082de5d8328527a7e4a0dcea39c26b Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 14:10:08 +0200 Subject: [PATCH 2/4] Mark advert cadence provisional during route backfill --- .../node_health_advert_intervals_test.go | 32 +++++++++++++++++-- cmd/server/routes.go | 1 + docs/api-spec.md | 8 +++-- public/home.css | 1 + public/home.js | 8 +++-- test-issue-304-my-mesh-e2e.js | 13 +++++--- 6 files changed, 51 insertions(+), 12 deletions(-) diff --git a/cmd/server/node_health_advert_intervals_test.go b/cmd/server/node_health_advert_intervals_test.go index e311fe826..c4714646a 100644 --- a/cmd/server/node_health_advert_intervals_test.go +++ b/cmd/server/node_health_advert_intervals_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) func TestNodeHealthAdvertIntervalsOptInRoleAndPrivacy(t *testing.T) { @@ -35,15 +36,26 @@ func TestNodeHealthAdvertIntervalsOptInRoleAndPrivacy(t *testing.T) { 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()) } - if _, ok := get(repeater, "?include=advertIntervals")["advertIntervals"]; !ok { + 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()) } @@ -59,11 +71,25 @@ func TestNodeHealthAdvertIntervalsOptInRoleAndPrivacy(t *testing.T) { 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 lookups.Load() != 3 { + 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") } } @@ -83,7 +109,7 @@ func TestNodeHealthAdvertIntervalsFailsClosedOnIdentityLookupError(t *testing.T) if w.Code != 200 { t.Fatalf("health status %d: %s", w.Code, w.Body.String()) } - if strings.Contains(w.Body.String(), "advertIntervals") || lookups.Load() != 0 { + 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 755f7c287..a0415e007 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2117,6 +2117,7 @@ func (s *Server) handleNodeHealth(w http.ResponseWriter, r *http.Request) { if hideErr == nil && !hidden { if adverts, advertErr := s.nodeAdvertRoutes(pubkey, time.Now()); advertErr == nil { result["advertIntervals"] = adverts.intervals + result["advertRouteBackfill"] = adverts.counts.RouteMaskBackfill } } } diff --git a/docs/api-spec.md b/docs/api-spec.md index 044dba4e6..93ed8802c 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -816,8 +816,12 @@ 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 }`. The field is absent by default, for -non-repeaters, and for hidden identities. The server reuses the bounded, +"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, diff --git a/public/home.css b/public/home.css index 1574eba31..63349b9c5 100644 --- a/public/home.css +++ b/public/home.css @@ -490,6 +490,7 @@ .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; } diff --git a/public/home.js b/public/home.js index 7854b3638..bd7a4d31d 100644 --- a/public/home.js +++ b/public/home.js @@ -319,7 +319,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) : ''; + const cadenceHtml = isRepeater ? renderMyMeshAdvertIntervals(h.advertIntervals, h.advertRouteBackfill) : ''; return `
@@ -443,7 +443,7 @@ dialog.showModal(); } - function renderMyMeshAdvertIntervals(intervals) { + function renderMyMeshAdvertIntervals(intervals, backfill) { const row = (label, estimate) => { if (!estimate) return `
${label} unavailable
`; const samples = Number(estimate.samples); @@ -464,9 +464,11 @@ ? ' · 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) + - '
Observed estimate, not configured interval · newest 20 adverts per type; missed receptions can skew it · see Node page
'; + provisional + '
Observed estimate ≠ setting · ≤20/type · missed receptions skew
'; } function buildSparkline(activity) { diff --git a/test-issue-304-my-mesh-e2e.js b/test-issue-304-my-mesh-e2e.js index 51c0e095b..7c541e049 100644 --- a/test-issue-304-my-mesh-e2e.js +++ b/test-issue-304-my-mesh-e2e.js @@ -7,7 +7,7 @@ const { chromium } = require('playwright'); const ROOT = path.join(__dirname, 'public'); const ORIGIN = 'http://127.0.0.1:18740'; -const KEYS = ['a'.repeat(64), 'b'.repeat(64), 'c'.repeat(64)]; +const KEYS = ['a'.repeat(64), 'b'.repeat(64), 'c'.repeat(64), 'd'.repeat(64)]; const now = Date.now(); const end = new Date(now).toISOString(); const start = new Date(now - 24 * 3600000).toISOString(); @@ -64,10 +64,12 @@ async function render(page, responses, width) { node: { name: `Repeater ${i}`, role: 'repeater' }, stats: { lastHeard: end, packetsToday: 2 }, observers: i === 0 ? names.map(observer_name => ({ observer_name })) : i === 1 ? [{ observer_name: 'One' }] : [], recentPackets: [], activity24h: i === 0 ? activity() : i === 1 ? activity(false) : undefined, + advertRouteBackfill: i === 0 ? { status: 'pending', remaining: 15 } : i === 1 ? { status: 'complete', remaining: 0 } : undefined, advertIntervals: i === 0 ? { 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 }, - } : i === 1 ? { zero_hop: { status: 'none_observed', samples: 0 }, flood: { status: 'irregular', samples: 5 } } : undefined, + } : i === 1 ? { zero_hop: { status: 'none_observed', samples: 0 }, flood: { status: 'irregular', samples: 5 } } + : i === 2 ? { zero_hop: { status: 'estimated', interval_s: 3600, samples: 4 }, flood: { status: 'too_few', samples: 1 } } : undefined, }])); await render(page, data, width); if (process.env.SCREENSHOT_PATH && width === 1280) { @@ -90,9 +92,12 @@ async function render(page, responses, width) { 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-cadence').innerText(), /observed estimate.*not configured/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.match(await cards.nth(2).locator('.mnc-advert-cadence').innerText(), /unavailable/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); 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'); From 7b37e1b652c8b31fa6721c4c0f83c4e6ecc97c6b Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 8 Oct 2026 08:32:25 +0000 Subject: [PATCH 3/4] test(home): reproduce My Mesh card click missing the card's health cache (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #352 moved the card's health request to ?include=advertIntervals but loadHealth still fetched the plain URL, so a card click became a second network request instead of a client-cache hit. CI's home-coverage E2E caught it as a race (the panel was still "Loading…"). Pin that a card click renders from the card's own URL, and that a node outside My Mesh keeps the plain URL (no advert scan). Co-Authored-By: Claude Opus 5.5 --- test-issue-304-my-mesh-e2e.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test-issue-304-my-mesh-e2e.js b/test-issue-304-my-mesh-e2e.js index 4b1d934e0..9cd2c9e26 100644 --- a/test-issue-304-my-mesh-e2e.js +++ b/test-issue-304-my-mesh-e2e.js @@ -211,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(); } From a5c782ae8d4238aa81dcf5693fd88606d641a6d6 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 8 Oct 2026 08:32:40 +0000 Subject: [PATCH 4/4] fix(home): reuse the My Mesh card's health URL for its health panel (#352) One healthPath() helper builds the card request and the panel request. For a claimed node the panel asks for the same ?include=advertIntervals URL the card already fetched, so the client cache answers it as before #352. Nodes outside My Mesh keep the plain URL and skip the advert scan. Update the #2027 harness comment that still said Full health used the plain URL. Co-Authored-By: Claude Opus 5.5 --- public/home.js | 10 ++++++++-- test-issue-2027-my-mesh-node-page-e2e.js | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/public/home.js b/public/home.js index f1004ccf2..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?include=advertIntervals', { 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 || []; @@ -532,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 eb1d1636b..a965b918a 100644 --- a/test-issue-2027-my-mesh-node-page-e2e.js +++ b/test-issue-2027-my-mesh-node-page-e2e.js @@ -155,7 +155,8 @@ function stub(page, path_, data) { return page.evaluate(({ p, d }) => { window.__apiResponders[p] = { data: d }; // My Mesh cards opt into advert intervals on their existing health - // request; Full health still fetches the original unqualified URL. + // 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 }); }