From 4130188f6510549bb7ba78bd02bfb8431c077340 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 12:47:41 +0200 Subject: [PATCH 1/9] Fix My Mesh repeater activity and observer cards (#304) --- .github/workflows/deploy.yml | 1 + cmd/server/node_activity_24h.go | 98 +++++++++++++++++ cmd/server/node_activity_304_test.go | 150 +++++++++++++++++++++++++++ cmd/server/store.go | 9 +- docs/api-spec.md | 25 ++++- public/home.css | 17 ++- public/home.js | 69 ++++++++---- test-issue-304-my-mesh-e2e.js | 117 +++++++++++++++++++++ 8 files changed, 464 insertions(+), 22 deletions(-) create mode 100644 cmd/server/node_activity_24h.go create mode 100644 cmd/server/node_activity_304_test.go create mode 100644 test-issue-304-my-mesh-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 229aa62b7..cf2730e3b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -518,6 +518,7 @@ jobs: BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1799-label-vocab-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-home-coverage-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 node test-issue-304-my-mesh-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 node test-issue-2027-my-mesh-node-page-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-path-inspector-coverage-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/cmd/server/node_activity_24h.go b/cmd/server/node_activity_24h.go new file mode 100644 index 000000000..b7b67ff85 --- /dev/null +++ b/cmd/server/node_activity_24h.go @@ -0,0 +1,98 @@ +package main + +import "time" + +// NodeActivityHour is one half-open hour of transmission activity. +type NodeActivityHour struct { + Start string `json:"start"` + End string `json:"end"` + Count int `json:"count"` +} + +// NodeActivity24h describes the last 24 rolling hours. Complete is false +// unless the in-memory index is known to cover the whole window. +type NodeActivity24h struct { + WindowStart string `json:"windowStart"` + WindowEnd string `json:"windowEnd"` + Buckets [24]NodeActivityHour `json:"buckets"` + CoverageStart *string `json:"coverageStart"` + Complete bool `json:"complete"` + start time.Time + end time.Time +} + +func newNodeActivity24h(now, coverageStart time.Time) NodeActivity24h { + end := now.UTC() + start := end.Add(-24 * time.Hour) + a := NodeActivity24h{ + WindowStart: start.Format(time.RFC3339Nano), + WindowEnd: end.Format(time.RFC3339Nano), + start: start, + end: end, + } + for i := range a.Buckets { + bucketStart := start.Add(time.Duration(i) * time.Hour) + a.Buckets[i] = NodeActivityHour{ + Start: bucketStart.Format(time.RFC3339Nano), + End: bucketStart.Add(time.Hour).Format(time.RFC3339Nano), + } + } + if !coverageStart.IsZero() { + coverageStart = coverageStart.UTC() + iso := coverageStart.Format(time.RFC3339Nano) + a.CoverageStart = &iso + a.Complete = !coverageStart.After(start) + } + return a +} + +func (a *NodeActivity24h) add(tx *StoreTx) { + if tx == nil { + return + } + when, err := time.Parse(time.RFC3339Nano, tx.FirstSeen) + if err != nil || when.Before(a.start) || !when.Before(a.end) { + return + } + idx := int(when.Sub(a.start) / time.Hour) + a.Buckets[idx].Count++ +} + +// nodeActivityCoverageStartLocked returns zero unless load and retention +// provide positive evidence of continuous in-memory coverage. A memory cap +// can evict the oldest rows after oldestLoaded was set, so use the current +// first row as the stricter lower bound when it is enabled. +func (s *PacketStore) nodeActivityCoverageStartLocked(now time.Time) time.Time { + if !s.loaded || !s.backgroundLoadDone.Load() || s.backgroundLoadFailed.Load() || + (s.retentionHours > 0 && s.retentionHours < 24) || len(s.packets) == 0 { + return time.Time{} + } + // The startup "done" gate accepts 90% of DB rows. That is not enough + // to call every empty hour a confirmed zero; require the exact count. + s.bgErrMu.RLock() + loadCoverage := s.loadCoverageRatio + s.bgErrMu.RUnlock() + if loadCoverage < 1 { + return time.Time{} + } + coverage, err := time.Parse(time.RFC3339Nano, s.oldestLoaded) + if err != nil { + return time.Time{} + } + if s.maxMemoryMB > 0 { + first, err := time.Parse(time.RFC3339Nano, s.packets[0].FirstSeen) + if err != nil { + return time.Time{} + } + if first.After(coverage) { + coverage = first + } + } + if s.retentionHours > 0 { + floor := now.UTC().Add(-time.Duration(s.retentionHours * float64(time.Hour))) + if floor.After(coverage) { + coverage = floor + } + } + return coverage +} diff --git a/cmd/server/node_activity_304_test.go b/cmd/server/node_activity_304_test.go new file mode 100644 index 000000000..8367148d5 --- /dev/null +++ b/cmd/server/node_activity_304_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/json" + "testing" + "time" +) + +func TestNodeActivity24hRollingBoundariesAndUniqueTransmissions(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + start := now.Add(-24 * time.Hour) + packets := make([]*StoreTx, 0, 30) + for i := 0; i < 25; i++ { + packets = append(packets, &StoreTx{ID: i + 1, FirstSeen: start.Add(time.Duration(i) * time.Hour).Format(time.RFC3339), ObservationCount: 3}) + } + packets = append(packets, + &StoreTx{ID: 26, FirstSeen: start.Add(-time.Second).Format(time.RFC3339)}, + &StoreTx{ID: 27, FirstSeen: now.Format(time.RFC3339)}, + &StoreTx{ID: 28, FirstSeen: "bad timestamp"}, + ) + got := newNodeActivity24h(now, start.Add(-time.Hour)) + for _, tx := range packets { + got.add(tx) + } + if len(got.Buckets) != 24 { + t.Fatalf("buckets=%d, want 24", len(got.Buckets)) + } + if got.WindowStart != start.Format(time.RFC3339) || got.WindowEnd != now.Format(time.RFC3339) { + t.Fatalf("window=%s..%s", got.WindowStart, got.WindowEnd) + } + for i, b := range got.Buckets { + if b.Count != 1 { + t.Errorf("bucket %d count=%d, want one transmission despite three observations", i, b.Count) + } + } +} + +func TestNodeActivity24hIncompleteWhenCoverageUnknown(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + got := newNodeActivity24h(now, time.Time{}) + if got.Complete || got.CoverageStart != nil { + t.Fatalf("empty history must not claim complete coverage: %+v", got) + } + for _, b := range got.Buckets { + if b.Count != 0 { + t.Fatalf("empty bucket count=%d", b.Count) + } + } +} + +func TestNodeHealthActivity24hUsesFullByNodeIndex(t *testing.T) { + srv, _ := setupTestServer(t) + const pubkey = "aabbccdd11223344" + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + start := now.Add(-24 * time.Hour) + packets := make([]*StoreTx, 0, 27) + for i := 0; i < 26; i++ { + packets = append(packets, &StoreTx{ID: i + 1, FirstSeen: start.Add(time.Duration(i%24) * time.Hour).Format(time.RFC3339), ObservationCount: 4}) + } + packets = append(packets, &StoreTx{ID: 27, FirstSeen: "malformed"}) + srv.store.mu.Lock() + srv.store.byNode[pubkey] = packets + srv.store.loaded = true + srv.store.loadCoverageRatio = 1 + srv.store.retentionHours = 168 + srv.store.oldestLoaded = start.Add(-time.Hour).Format(time.RFC3339) + srv.store.packets = []*StoreTx{{ID: 100, FirstSeen: srv.store.oldestLoaded}} + srv.store.backgroundLoadDone.Store(true) + srv.store.mu.Unlock() + health, err := srv.store.getNodeHealthAt(pubkey, now) + if err != nil { + t.Fatal(err) + } + activity, ok := health["activity24h"].(NodeActivity24h) + if !ok || !activity.Complete || activity.CoverageStart == nil { + t.Fatalf("activity coverage: %#v", health["activity24h"]) + } + if activity.Buckets[0].Count != 2 || activity.Buckets[23].Count != 1 { + t.Fatalf("buckets[0]=%d [23]=%d", activity.Buckets[0].Count, activity.Buckets[23].Count) + } + if len(health["recentPackets"].([]map[string]interface{})) != 20 { + t.Fatal("recentPackets compatibility limit changed") + } + encoded, err := json.Marshal(health) + if err != nil { + t.Fatal(err) + } + var body struct { + Activity24h struct { + Buckets []NodeActivityHour `json:"buckets"` + Complete bool `json:"complete"` + } `json:"activity24h"` + } + if err := json.Unmarshal(encoded, &body); err != nil || !body.Activity24h.Complete || len(body.Activity24h.Buckets) != 24 { + t.Fatalf("JSON contract invalid: %v %s", err, encoded) + } +} + +func TestNodeActivity24hCoverageRequiresLoadedHistory(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + start := now.Add(-24 * time.Hour) + s := &PacketStore{loaded: true, loadCoverageRatio: 1, oldestLoaded: start.Add(-time.Hour).Format(time.RFC3339), retentionHours: 168, + packets: []*StoreTx{{FirstSeen: start.Add(-time.Hour).Format(time.RFC3339)}}} + s.backgroundLoadDone.Store(true) + if got := s.nodeActivityCoverageStartLocked(now); got.After(start) || got.IsZero() { + t.Fatalf("complete load coverage=%v", got) + } + s.loadCoverageRatio = 0.90 + if got := s.nodeActivityCoverageStartLocked(now); !got.IsZero() { + t.Fatalf("90%% startup gate is not full coverage, got %v", got) + } + s.loadCoverageRatio = 1 + s.maxMemoryMB = 100 + s.packets[0].FirstSeen = start.Add(time.Hour).Format(time.RFC3339) + if got := s.nodeActivityCoverageStartLocked(now); !got.After(start) { + t.Fatalf("memory cap evicted first hour, coverage=%v", got) + } + s.backgroundLoadFailed.Store(true) + if got := s.nodeActivityCoverageStartLocked(now); !got.IsZero() { + t.Fatalf("failed background load must be unknown, coverage=%v", got) + } + s.backgroundLoadFailed.Store(false) + s.retentionHours = 12 + if got := s.nodeActivityCoverageStartLocked(now); !got.IsZero() { + t.Fatalf("12h retention cannot cover 24h, coverage=%v", got) + } + s.retentionHours = 168 + s.packets = nil + if got := s.nodeActivityCoverageStartLocked(now); !got.IsZero() { + t.Fatalf("empty index cannot prove coverage, coverage=%v", got) + } +} + +func BenchmarkNodeActivity24h30K(b *testing.B) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + packets := make([]*StoreTx, 30000) + for i := range packets { + packets[i] = &StoreTx{ID: i + 1, FirstSeen: now.Add(-time.Duration(i%1440) * time.Minute).Format(time.RFC3339)} + } + b.ResetTimer() + for n := 0; n < b.N; n++ { + a := newNodeActivity24h(now, now.Add(-25*time.Hour)) + for _, tx := range packets { + a.add(tx) + } + if a.Buckets[23].Count == 0 { + b.Fatal("unexpected empty final hour") + } + } +} diff --git a/cmd/server/store.go b/cmd/server/store.go index 7ad0237ad..49bdc4037 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -10450,6 +10450,10 @@ func (s *PacketStore) GetBulkHealth(limit int, region, area string) []map[string // GetNodeHealth returns health info for a single node using in-memory data. func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, error) { + return s.getNodeHealthAt(pubkey, time.Now().UTC()) +} + +func (s *PacketStore) getNodeHealthAt(pubkey string, now time.Time) (map[string]interface{}, error) { // Fetch node info from DB (fast single-row lookup) node, err := s.db.GetNodeByPubkey(pubkey) if err != nil { @@ -10477,7 +10481,8 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro packets := s.byNode[pubkey] activityKey := strings.ToLower(pubkey) - todayStart := time.Now().UTC().Truncate(24 * time.Hour).Format(time.RFC3339) + todayStart := now.UTC().Truncate(24 * time.Hour).Format(time.RFC3339) + activity := newNodeActivity24h(now, s.nodeActivityCoverageStartLocked(now)) var packetsToday int var snrSum float64 @@ -10494,6 +10499,7 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro }{} for _, pkt := range packets { + activity.add(pkt) totalObservations += pkt.ObservationCount if pkt.FirstSeen > todayStart { packetsToday++ @@ -10628,6 +10634,7 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro AvgHops: &avgHops, LastHeard: timestampPointer(lastHeard), LastAdvert: timestampPointer(lastAdvert), }, "recentPackets": recentPackets, + "activity24h": activity, }, nil } diff --git a/docs/api-spec.md b/docs/api-spec.md index 56e9d5d6e..56f8adbb8 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -786,10 +786,33 @@ Detailed health information for a single node. // Packet fields (see Packet Object) minus `observations` "observation_count": number // added for display } - ] + ], + "activity24h": { + "windowStart": string (ISO UTC), // inclusive: request time minus 24 hours + "windowEnd": string (ISO UTC), // exclusive: request time + "buckets": [ // always 24 rolling one-hour buckets, oldest first + { "start": string (ISO UTC), "end": string (ISO UTC), "count": number } + ], + "coverageStart": string (ISO UTC) | null, + "complete": boolean + } } ``` +`activity24h.count` counts unique transmissions associated with this node in +the in-memory node index, using each transmission's `first_seen` time. Multiple +observations of one transmission count once. Association can come from node +identity or a resolved path; it does not prove that a repeater forwarded the +packet. Bucket boundaries are half-open `[start, end)` and move with request +time rather than aligning to clock hours. A malformed `first_seen` is ignored. +`complete` is true only when successful loading reports 100% of the retained +transmissions in memory and the retention/index bounds cover the whole 24-hour +window. The server's general background-load “done” threshold of 90% is not +enough for this field. If `complete` is false, zero-count +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). + ### Response `404` ```json diff --git a/public/home.css b/public/home.css index 8b01c2c31..86b95d7c5 100644 --- a/public/home.css +++ b/public/home.css @@ -471,9 +471,24 @@ .mnc-val { font: 700 1rem var(--font); color: var(--text); } .mnc-lbl { font-size: .65rem; color: var(--text-muted); margin-top: 2px; } -.mnc-observers { font-size: .75rem; color: var(--text-muted); margin-bottom: 8px; line-height: 1.4; } +.mnc-observers { font-size: .75rem; color: var(--text-muted); margin-bottom: 8px; line-height: 1.4; min-height: 22px; } +.mnc-observer-name { display: inline-block; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; } +.mnc-view-all { margin-left: 4px; } +.mnc-observers-dialog { + width: min(440px, calc(100vw - 24px)); max-height: min(70vh, 600px); + position: fixed; inset: 0; margin: auto; + padding: 20px; border: 1px solid var(--border); border-radius: 12px; + background: var(--surface-1); color: var(--text); +} +.mnc-observers-dialog::backdrop { background: var(--surface-0); opacity: .8; } +.mnc-dialog-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.mnc-dialog-header h2 { margin: 0 0 12px; font-size: 1rem; overflow-wrap: anywhere; } +.mnc-dialog-close { border: 1px solid var(--border); border-radius: 6px; padding: 7px 10px; background: var(--surface-2); color: var(--text); cursor: pointer; } +.mnc-dialog-list { max-height: 50vh; overflow: auto; margin: 0; padding-left: 24px; } +.mnc-dialog-list li { overflow-wrap: anywhere; margin-bottom: 5px; } .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; } .spark-bars { display: flex; align-items: flex-end; gap: 2px; height: 28px; } .spark-bar { flex: 1; background: var(--accent); border-radius: 1px; min-width: 0; } diff --git a/public/home.js b/public/home.js index 7c456d937..dc5546c2b 100644 --- a/public/home.js +++ b/public/home.js @@ -115,6 +115,10 @@ ${hasNodes ? '
Loading your nodes…
' : '
'} + +

+ +
${!hasNodes ? `
@@ -263,6 +267,8 @@ clearTimeout(searchTimeout); if (searchAbort) { searchAbort.abort(); searchAbort = null; } if (miniMap) { miniMap.remove(); miniMap = null; } + const observersDialog = document.getElementById('homeObserversDialog'); + if (observersDialog?.open) observersDialog.close(); if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } } @@ -288,12 +294,15 @@ return; } + const observerLists = new Map(); + const cards = await Promise.all(myNodes.map(async (mn) => { try { const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth }); const node = h.node || {}; const stats = h.stats || {}; const obs = h.observers || []; + observerLists.set(mn.pubkey, obs); const age = stats.lastHeard ? Date.now() - new Date(stats.lastHeard).getTime() : null; const status = age === null ? 'silent' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'healthy' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'degraded' : 'silent'; @@ -307,8 +316,9 @@ const snrLabel = snrVal != null ? (snrVal > 10 ? 'Excellent' : snrVal > 0 ? 'Good' : snrVal > -5 ? 'Marginal' : 'Poor') : null; const snrColor = snrVal != null ? (snrVal > 10 ? 'var(--status-green)' : snrVal > 0 ? 'var(--accent)' : snrVal > -5 ? 'var(--status-yellow)' : 'var(--status-red)') : '#6b7280'; - // Build sparkline from recent packets (packet timestamps → hourly buckets) - const sparkHtml = buildSparkline(h.recentPackets || []); + const sparkHtml = buildSparkline(h.activity24h); + const isRepeater = String(node.role || '').toLowerCase() === 'repeater'; + const preview = isRepeater ? obs.slice(0, 3) : obs; return `
@@ -336,8 +346,8 @@
Avg hops
- ${obs.length ? `
Heard by: ${obs.map(o => escapeHtml(o.observer_name || o.observer_id)).join(', ')}
` : ''} - ${sparkHtml ? `
${sparkHtml}
` : ''} + ${obs.length ? `
Heard by: ${preview.map(o => `${escapeHtml(o.observer_name || o.observer_id || 'Unknown')}`).join(', ')}${isRepeater && obs.length > 3 ? ` ` : ''}
` : ''} +
${sparkHtml}
@@ -385,6 +395,7 @@ if (btn.dataset.action === 'node') window.location.hash = '#/nodes/' + encodeURIComponent(btn.dataset.key); if (btn.dataset.action === 'health') loadHealth(btn.dataset.key); if (btn.dataset.action === 'packets') window.location.hash = '#/packets/' + btn.dataset.key; + if (btn.dataset.action === 'observers') showObservers(btn, observerLists.get(btn.dataset.key) || []); }); }); @@ -407,23 +418,43 @@ }); } - function buildSparkline(packets) { - if (!packets.length) return ''; - // Group into hourly buckets over last 24h - const now = Date.now(); - const buckets = new Array(24).fill(0); - packets.forEach(p => { - const t = new Date(p.timestamp || p.created_at).getTime(); - const hoursAgo = Math.floor((now - t) / 3600000); - if (hoursAgo >= 0 && hoursAgo < 24) buckets[23 - hoursAgo]++; + function showObservers(trigger, observers) { + const dialog = document.getElementById('homeObserversDialog'); + if (!dialog) return; + const cardName = trigger.closest('.my-node-card')?.querySelector('.mnc-name')?.textContent || 'Node'; + dialog.querySelector('#homeObserversTitle').textContent = `${cardName} · ${observers.length} observers`; + const list = dialog.querySelector('.mnc-dialog-list'); + const fragment = document.createDocumentFragment(); + observers.forEach(observer => { + const item = document.createElement('li'); + item.textContent = observer.observer_name || observer.observer_id || 'Unknown'; + fragment.appendChild(item); }); - const max = Math.max(...buckets, 1); - const bars = buckets.map(v => { - const h = Math.max(2, Math.round((v / max) * 24)); - const opacity = v > 0 ? 0.4 + (v / max) * 0.6 : 0.1; - return `
`; + list.replaceChildren(fragment); + dialog.querySelector('.mnc-dialog-close').onclick = () => dialog.close(); + dialog.onclick = (event) => { + if (event.target !== dialog) return; + const rect = dialog.getBoundingClientRect(); + if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) dialog.close(); + }; + dialog.onclose = () => { if (trigger.isConnected) trigger.focus(); }; + dialog.showModal(); + } + + function buildSparkline(activity) { + const label = '
Node-associated transmissions · last 24h
'; + const buckets = activity?.buckets; + const valid = activity?.complete === true && Array.isArray(buckets) && buckets.length === 24 && + buckets.every(b => Number.isInteger(b.count) && b.count >= 0 && Number.isFinite(Date.parse(b.start)) && Number.isFinite(Date.parse(b.end))); + if (!valid) return label + '
Activity unavailable: 24h history incomplete
'; + const max = Math.max(1, ...buckets.map(b => b.count)); + const bars = buckets.map(b => { + const height = Math.max(2, Math.round((b.count / max) * 24)); + const opacity = b.count > 0 ? 0.4 + (b.count / max) * 0.6 : 0.1; + const range = `${new Date(b.start).toLocaleString()} to ${new Date(b.end).toLocaleString()}`; + return ``; }).join(''); - return `
24h activity
${bars}
`; + return `${label}
${bars}
`; } // ==================== STATS ==================== diff --git a/test-issue-304-my-mesh-e2e.js b/test-issue-304-my-mesh-e2e.js new file mode 100644 index 000000000..cba798866 --- /dev/null +++ b/test-issue-304-my-mesh-e2e.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +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 now = Date.now(); +const end = new Date(now).toISOString(); +const start = new Date(now - 24 * 3600000).toISOString(); +const activity = (complete = true) => ({ + windowStart: start, windowEnd: end, coverageStart: complete ? start : new Date(now - 8 * 3600000).toISOString(), complete, + buckets: Array.from({ length: 24 }, (_, i) => ({ + start: new Date(now - (24 - i) * 3600000).toISOString(), + end: new Date(now - (23 - i) * 3600000).toISOString(), + count: i === 3 ? 35 : 0, + })), +}); + +async function render(page, responses, width) { + await page.setViewportSize({ width, height: 800 }); + await page.route('**/*', route => { + const u = new URL(route.request().url()); + if (u.origin !== ORIGIN) return route.abort(); + if (u.pathname === '/icons/phosphor-sprite.svg') return route.fulfill({ contentType: 'image/svg+xml', body: fs.readFileSync(path.join(ROOT, 'icons/phosphor-sprite.svg')) }); + return route.fulfill({ contentType: 'text/html', body: '
' }); + }); + await page.goto(ORIGIN + '/'); + await page.addStyleTag({ path: path.join(ROOT, 'style.css') }); + await page.addStyleTag({ path: path.join(ROOT, 'home.css') }); + for (const file of ['app.js', 'roles.js']) await page.addScriptTag({ path: path.join(ROOT, file) }); + await page.evaluate((data) => { + window.removeEventListener('hashchange', navigate); + window.fixturePages = {}; + registerPage = (name, mod) => { window.fixturePages[name] = mod; }; + api = async (route) => { + if (route === '/stats') return { totalNodes: 3 }; + if (route in data) return data[route]; + throw Error('Unexpected API: ' + route); + }; + localStorage.setItem('meshcore-user-level', 'experienced'); + localStorage.setItem('meshcore-my-nodes', JSON.stringify(Object.keys(data).map(route => ({ pubkey: route.split('/')[2], name: 'Node' })))); + }, responses); + await page.addScriptTag({ path: path.join(ROOT, 'home.js') }); + await page.evaluate(() => window.fixturePages.home.init(document.getElementById('fixture'))); + await page.waitForFunction((n) => document.querySelectorAll('.my-node-card').length === n, Object.keys(responses).length); +} + +(async () => { + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); + try { + for (const width of [1280, 390]) { + const context = await browser.newContext({ viewport: { width, height: 800 }, hasTouch: width < 500, isMobile: width < 500 }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + 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`, { + 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, + }])); + await render(page, data, width); + if (process.env.SCREENSHOT_PATH && width === 1280) { + await page.screenshot({ path: process.env.SCREENSHOT_PATH, fullPage: true }); + } + const cards = page.locator('.my-node-card'); + const large = cards.nth(0); + assert.equal(await large.locator('.mnc-observers .mnc-observer-name').count(), 3); + assert.equal(await large.locator('.mnc-observers .mnc-observer-name').nth(1).evaluate(el => el.scrollWidth > el.clientWidth), true); + assert.equal(await large.locator('.mnc-observers svg').count(), 0); + assert.equal(await cards.nth(1).locator('.mnc-view-all').count(), 0); + assert.equal(await cards.nth(2).locator('.mnc-observers').count(), 0); + assert.equal(await large.locator('.home-spark-bar').count(), 24); + assert.match(await large.locator('.mnc-spark').innerText(), /Node-associated transmissions.*last 24h/); + assert.match(await large.locator('.home-spark-bar').nth(3).getAttribute('aria-label'), /35 node-associated transmissions/); + assert.match(await large.locator('.home-spark-bar').nth(3).getAttribute('title'), /35 node-associated transmissions/); + assert.match(await large.locator('.home-spark-bar').first().getAttribute('aria-label'), /0 node-associated transmissions/); + assert.equal(await cards.nth(1).locator('.home-spark-bar').count(), 0); + 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); + 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'); + await open.focus(); + await page.keyboard.press('Enter'); + const dialog = page.locator('#homeObserversDialog'); + await dialog.waitFor({ state: 'visible' }); + assert.equal(await dialog.locator('li').count(), 51); + assert.equal(await dialog.locator('img').count(), 0); + assert.equal(await page.locator('#homeHealth.visible').count(), 0); + await page.keyboard.press('Escape'); + assert.equal(await dialog.isVisible(), false); + assert.equal(await open.evaluate(el => el === document.activeElement), true); + await open.click(); + await dialog.waitFor({ state: 'visible' }); + const dialogBox = await dialog.boundingBox(); + assert(dialogBox && dialogBox.width <= width - 16, 'dialog must fit viewport'); + await page.mouse.click(2, 2); + assert.equal(await dialog.isVisible(), false); + if (width < 500) { + await open.tap(); + await dialog.waitFor({ state: 'visible' }); + await dialog.locator('.mnc-dialog-close').tap(); + } + assert.deepEqual(errors, []); + await context.close(); + } + console.log('My Mesh #304: desktop/mobile card, sparkline, coverage and observer dialog passed'); + } finally { await browser.close(); } +})().catch(e => { console.error(e); process.exitCode = 1; }); From c8c14f460507667ec0fb2aa54fd332c45c1a2df8 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 12:51:40 +0200 Subject: [PATCH 2/9] Handle unlimited retention in My Mesh activity coverage --- cmd/server/node_activity_24h.go | 5 ++++- cmd/server/node_activity_304_test.go | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cmd/server/node_activity_24h.go b/cmd/server/node_activity_24h.go index b7b67ff85..7df7a4f12 100644 --- a/cmd/server/node_activity_24h.go +++ b/cmd/server/node_activity_24h.go @@ -72,7 +72,10 @@ func (s *PacketStore) nodeActivityCoverageStartLocked(now time.Time) time.Time { s.bgErrMu.RLock() loadCoverage := s.loadCoverageRatio s.bgErrMu.RUnlock() - if loadCoverage < 1 { + // Unlimited retention with a hot-start window has no background fill and + // leaves loadCoverageRatio at zero. The window itself can still prove 24h + // coverage when it is at least 24h wide; oldestLoaded is checked below. + if loadCoverage < 1 && !(s.retentionHours <= 0 && s.hotStartupHours >= 24) { return time.Time{} } coverage, err := time.Parse(time.RFC3339Nano, s.oldestLoaded) diff --git a/cmd/server/node_activity_304_test.go b/cmd/server/node_activity_304_test.go index 8367148d5..13a3003d1 100644 --- a/cmd/server/node_activity_304_test.go +++ b/cmd/server/node_activity_304_test.go @@ -109,6 +109,18 @@ func TestNodeActivity24hCoverageRequiresLoadedHistory(t *testing.T) { if got := s.nodeActivityCoverageStartLocked(now); !got.IsZero() { t.Fatalf("90%% startup gate is not full coverage, got %v", got) } + s.retentionHours = 0 + s.hotStartupHours = 25 + s.loadCoverageRatio = 0 + if got := s.nodeActivityCoverageStartLocked(now); got.After(start) || got.IsZero() { + t.Fatalf("unlimited retention with a fully loaded 25h hot window must be complete, got %v", got) + } + s.hotStartupHours = 12 + if got := s.nodeActivityCoverageStartLocked(now); !got.IsZero() { + t.Fatalf("a 12h hot window cannot prove 24h coverage, got %v", got) + } + s.hotStartupHours = 0 + s.retentionHours = 168 s.loadCoverageRatio = 1 s.maxMemoryMB = 100 s.packets[0].FirstSeen = start.Add(time.Hour).Format(time.RFC3339) From b039258595011786853fc7bac9fdb22285f50a0a Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 12:53:33 +0200 Subject: [PATCH 3/9] Test unlimited retention without hot start --- cmd/server/node_activity_304_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/server/node_activity_304_test.go b/cmd/server/node_activity_304_test.go index 13a3003d1..2a5146a8f 100644 --- a/cmd/server/node_activity_304_test.go +++ b/cmd/server/node_activity_304_test.go @@ -120,8 +120,11 @@ func TestNodeActivity24hCoverageRequiresLoadedHistory(t *testing.T) { t.Fatalf("a 12h hot window cannot prove 24h coverage, got %v", got) } s.hotStartupHours = 0 + s.loadCoverageRatio = 1 // RunStartupLoad sets this after full synchronous loading. + if got := s.nodeActivityCoverageStartLocked(now); got.After(start) || got.IsZero() { + t.Fatalf("unlimited retention with no hot window loads full DB, got %v", got) + } s.retentionHours = 168 - s.loadCoverageRatio = 1 s.maxMemoryMB = 100 s.packets[0].FirstSeen = start.Add(time.Hour).Format(time.RFC3339) if got := s.nodeActivityCoverageStartLocked(now); !got.After(start) { From 1d0fa095951a23cf8527059c041e86bdb2f198af Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 19:45:40 +0200 Subject: [PATCH 4/9] fix(server): retention-aware 24h coverage gate, lock order, typed health (#351 review F1,F3,F4,F5,F6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (blocking): RunStartupLoad's hotStartupHours==0 branch measured loadCoverageRatio against a whole-table COUNT(*), so on the shipped default (retentionHours=168, hotStartupHours=0) with a DB holding more than 168h of history the ratio was structurally below 1 and the 24h activity gate never proved completeness — the chart showed "Activity unavailable" forever. Divide by the rows LoadChunked actually targeted (the retention/hot-start-filtered count it already computes), matching docs/api-spec.md and the background-fill path. New Test351F1StartupCoverageHonoursRetentionWindow covers BOTH startup paths (hotStartupHours==0 and >0). F3 (major): add TestNodeActivity24hKnownButShorterCoverageIsIncomplete — coverage that is known but shorter than 24h must read as incomplete, not as confirmed silence. Kills mutant M3 (unconditional Complete=true), which survived the whole suite before. F4 (nit): read the bgErrMu-guarded coverage ratio in GetNodeHealth BEFORE taking s.mu and pass it into nodeActivityCoverageStartLocked, so the store never nests s.mu -> bgErrMu (bgErrMu exists precisely so readers avoid s.mu). F5 (nit): drop the getNodeHealthAt extraction; inject now into GetNodeHealth directly so no second untyped-map-returning signature is added (AGENTS.md). F6 (nit): skip the per-packet RFC3339Nano parse for transmissions that sort below the window start (the common case for long in-memory history). Only the lower bound is string-filtered — the upper bound carries now's sub-second precision and cannot be compared safely as a string. Guarded by TestNodeActivity24hAddPreFilterKeepsBoundaryPackets. Relates to #304 --- cmd/server/chunked_load.go | 19 ++- cmd/server/count_sort_tie_order_321_test.go | 2 +- cmd/server/node_activity_24h.go | 25 ++- cmd/server/node_activity_304_test.go | 164 ++++++++++++++++++-- cmd/server/node_liveness_regression_test.go | 4 +- cmd/server/routes.go | 2 +- cmd/server/store.go | 31 +++- 7 files changed, 219 insertions(+), 28 deletions(-) diff --git a/cmd/server/chunked_load.go b/cmd/server/chunked_load.go index c0d5bd557..c34455a1e 100644 --- a/cmd/server/chunked_load.go +++ b/cmd/server/chunked_load.go @@ -245,10 +245,16 @@ func (s *PacketStore) RunStartupLoad(chunkSize int) error { s.mu.RLock() loadedCount := int64(len(s.packets)) s.mu.RUnlock() - var totalInDB int64 - if err := s.db.conn.QueryRow(`SELECT COUNT(*) FROM transmissions`).Scan(&totalInDB); err != nil { - totalInDB = -1 - } + // #351 F1: measure coverage against the rows LoadChunked actually + // targeted (retention/hot-start-filtered), NOT a raw COUNT(*) over + // the whole table. With the shipped default (retentionHours=168, + // hotStartupHours=0) and a DB holding more than 168h of history, a + // whole-table count makes this ratio structurally < 1 forever, so + // the 24h activity coverage gate never proves completeness even + // though every retained row is in memory. docs/api-spec.md defines + // coverage against the RETAINED transmissions, and the bg-loader + // path already measures it that way. + totalInDB := s.loadWindowTotalInDB var ratio float64 switch { case totalInDB <= 0: @@ -366,6 +372,11 @@ func (s *PacketStore) LoadChunked(chunkSize int) error { if err := s.db.conn.QueryRow(countSQL).Scan(&totalInDB); err != nil { totalInDB = -1 } + // #351 F1: publish the retention/hot-start-filtered total so + // RunStartupLoad's hotStartupHours==0 branch can compute a coverage + // ratio against the rows it actually targeted instead of the whole + // table. Same goroutine, read-after-return — see field godoc. + s.loadWindowTotalInDB = int64(totalInDB) // Memory cap honoured by clamping the maximum cursor walk. var maxPackets int64 diff --git a/cmd/server/count_sort_tie_order_321_test.go b/cmd/server/count_sort_tie_order_321_test.go index d91cc2549..9e11280ba 100644 --- a/cmd/server/count_sort_tie_order_321_test.go +++ b/cmd/server/count_sort_tie_order_321_test.go @@ -148,7 +148,7 @@ func TestNodeHealthObserverRowsTieOrder_321(t *testing.T) { s := newHealthTieStore(t, pubkey, counts) assertSameEveryRun(t, "GetNodeHealth observers observer_id", want, func() interface{} { - res, err := s.GetNodeHealth(pubkey) + res, err := s.GetNodeHealth(pubkey, time.Now().UTC()) if err != nil { t.Fatalf("GetNodeHealth: %v", err) } diff --git a/cmd/server/node_activity_24h.go b/cmd/server/node_activity_24h.go index 7df7a4f12..1789f2744 100644 --- a/cmd/server/node_activity_24h.go +++ b/cmd/server/node_activity_24h.go @@ -50,6 +50,20 @@ func (a *NodeActivity24h) add(tx *StoreTx) { if tx == nil { return } + // #351 F6: cheap pre-filter before the RFC3339Nano parse. WindowStart is + // the window lower bound at full nanosecond precision, so any FirstSeen + // that sorts strictly below it is older than the window and is dropped + // without parsing — the common case for a node whose in-memory history + // spans well past 24h. A same-instant stamp at lower precision sorts + // >= WindowStart (its shorter string compares greater once WindowStart's + // fraction digits run out), so an in-window packet is never skipped here; + // the exact bounds check below still runs for everything that survives. + // The upper bound is deliberately NOT string-filtered: WindowEnd carries + // now's sub-second precision, so a whole-second stamp inside the final + // second would sort after it and be dropped incorrectly. + if tx.FirstSeen < a.WindowStart { + return + } when, err := time.Parse(time.RFC3339Nano, tx.FirstSeen) if err != nil || when.Before(a.start) || !when.Before(a.end) { return @@ -62,16 +76,19 @@ func (a *NodeActivity24h) add(tx *StoreTx) { // provide positive evidence of continuous in-memory coverage. A memory cap // can evict the oldest rows after oldestLoaded was set, so use the current // first row as the stricter lower bound when it is enabled. -func (s *PacketStore) nodeActivityCoverageStartLocked(now time.Time) time.Time { +// +// #351 F4: loadCoverage is read by the caller (getNodeHealthAt) BEFORE it +// takes s.mu and passed in here, so this function never acquires bgErrMu +// while holding s.mu. bgErrMu exists precisely so its readers need not +// synchronise on s.mu (store.go:530); nesting s.mu → bgErrMu would be the +// first such site in the codebase and would quietly undermine that contract. +func (s *PacketStore) nodeActivityCoverageStartLocked(now time.Time, loadCoverage float64) time.Time { if !s.loaded || !s.backgroundLoadDone.Load() || s.backgroundLoadFailed.Load() || (s.retentionHours > 0 && s.retentionHours < 24) || len(s.packets) == 0 { return time.Time{} } // The startup "done" gate accepts 90% of DB rows. That is not enough // to call every empty hour a confirmed zero; require the exact count. - s.bgErrMu.RLock() - loadCoverage := s.loadCoverageRatio - s.bgErrMu.RUnlock() // Unlimited retention with a hot-start window has no background fill and // leaves loadCoverageRatio at zero. The window itself can still prove 24h // coverage when it is at least 24h wide; oldestLoaded is checked below. diff --git a/cmd/server/node_activity_304_test.go b/cmd/server/node_activity_304_test.go index 2a5146a8f..40567bc42 100644 --- a/cmd/server/node_activity_304_test.go +++ b/cmd/server/node_activity_304_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "path/filepath" "testing" "time" ) @@ -67,7 +68,7 @@ func TestNodeHealthActivity24hUsesFullByNodeIndex(t *testing.T) { srv.store.packets = []*StoreTx{{ID: 100, FirstSeen: srv.store.oldestLoaded}} srv.store.backgroundLoadDone.Store(true) srv.store.mu.Unlock() - health, err := srv.store.getNodeHealthAt(pubkey, now) + health, err := srv.store.GetNodeHealth(pubkey, now) if err != nil { t.Fatal(err) } @@ -102,50 +103,193 @@ func TestNodeActivity24hCoverageRequiresLoadedHistory(t *testing.T) { s := &PacketStore{loaded: true, loadCoverageRatio: 1, oldestLoaded: start.Add(-time.Hour).Format(time.RFC3339), retentionHours: 168, packets: []*StoreTx{{FirstSeen: start.Add(-time.Hour).Format(time.RFC3339)}}} s.backgroundLoadDone.Store(true) - if got := s.nodeActivityCoverageStartLocked(now); got.After(start) || got.IsZero() { + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); got.After(start) || got.IsZero() { t.Fatalf("complete load coverage=%v", got) } s.loadCoverageRatio = 0.90 - if got := s.nodeActivityCoverageStartLocked(now); !got.IsZero() { + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.IsZero() { t.Fatalf("90%% startup gate is not full coverage, got %v", got) } s.retentionHours = 0 s.hotStartupHours = 25 s.loadCoverageRatio = 0 - if got := s.nodeActivityCoverageStartLocked(now); got.After(start) || got.IsZero() { + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); got.After(start) || got.IsZero() { t.Fatalf("unlimited retention with a fully loaded 25h hot window must be complete, got %v", got) } s.hotStartupHours = 12 - if got := s.nodeActivityCoverageStartLocked(now); !got.IsZero() { + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.IsZero() { t.Fatalf("a 12h hot window cannot prove 24h coverage, got %v", got) } s.hotStartupHours = 0 s.loadCoverageRatio = 1 // RunStartupLoad sets this after full synchronous loading. - if got := s.nodeActivityCoverageStartLocked(now); got.After(start) || got.IsZero() { + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); got.After(start) || got.IsZero() { t.Fatalf("unlimited retention with no hot window loads full DB, got %v", got) } s.retentionHours = 168 s.maxMemoryMB = 100 s.packets[0].FirstSeen = start.Add(time.Hour).Format(time.RFC3339) - if got := s.nodeActivityCoverageStartLocked(now); !got.After(start) { + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.After(start) { t.Fatalf("memory cap evicted first hour, coverage=%v", got) } s.backgroundLoadFailed.Store(true) - if got := s.nodeActivityCoverageStartLocked(now); !got.IsZero() { + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.IsZero() { t.Fatalf("failed background load must be unknown, coverage=%v", got) } s.backgroundLoadFailed.Store(false) s.retentionHours = 12 - if got := s.nodeActivityCoverageStartLocked(now); !got.IsZero() { + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.IsZero() { t.Fatalf("12h retention cannot cover 24h, coverage=%v", got) } s.retentionHours = 168 s.packets = nil - if got := s.nodeActivityCoverageStartLocked(now); !got.IsZero() { + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.IsZero() { t.Fatalf("empty index cannot prove coverage, coverage=%v", got) } } +// TestNodeActivity24hKnownButShorterCoverageIsIncomplete covers #351 F3 / +// #304 ("distinguish known zero activity from missing/incomplete coverage"). +// When coverage is KNOWN but reaches back less than the full 24h window, the +// window must report incomplete — a zero bucket inside the uncovered span is +// "unknown", not a confirmed quiet hour. +// +// Kills mutant M3: replacing `a.Complete = !coverageStart.After(start)` with +// an unconditional `a.Complete = true` whenever coverage is known. That +// mutant survived the entire cmd/server suite before this test (review +// finding 3). +func TestNodeActivity24hKnownButShorterCoverageIsIncomplete(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + start := now.Add(-24 * time.Hour) + + // Coverage known, but only 8h deep — short of the 24h window. + short := newNodeActivity24h(now, now.Add(-8*time.Hour)) + if short.CoverageStart == nil { + t.Fatalf("coverage start must be reported when it is known: %+v", short) + } + if short.Complete { + t.Fatalf("coverage reaching back only 8h must not mark a 24h window complete: %+v", short) + } + + // Boundary the mutant would also mask: coverage exactly at the window + // start IS complete, so the assertion above is testing the comparison, + // not merely that Complete is ever false. + if full := newNodeActivity24h(now, start); !full.Complete || full.CoverageStart == nil { + t.Fatalf("coverage back to the window start must be complete: %+v", full) + } + + // And one nanosecond past the start is still incomplete (half the point + // of the strict comparison). + if justShort := newNodeActivity24h(now, start.Add(time.Nanosecond)); justShort.Complete { + t.Fatalf("coverage starting after the window start must be incomplete: %+v", justShort) + } +} + +// TestNodeActivity24hAddPreFilterKeepsBoundaryPackets covers #351 F6: add()'s +// cheap string pre-filter trims ONLY the lower bound, so it must never drop an +// in-window packet. With a sub-second `now`, WindowEnd carries a fraction, so a +// whole-second stamp inside the final second sorts lexicographically AFTER +// WindowEnd — a string upper-bound filter would wrongly drop it; the exact time +// comparison must keep it. A stamp exactly at the (fractional) start is kept too. +func TestNodeActivity24hAddPreFilterKeepsBoundaryPackets(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 500_000_000, time.UTC) // .5s precision + start := now.Add(-24 * time.Hour) + + final := newNodeActivity24h(now, start) + // 100ms before now → final (23rd) bucket, whole-second precision. + final.add(&StoreTx{FirstSeen: now.Add(-100 * time.Millisecond).Truncate(time.Second).Format(time.RFC3339)}) + if final.Buckets[23].Count != 1 { + t.Fatalf("final-second whole-second packet must land in bucket 23, got %d", final.Buckets[23].Count) + } + + atStart := newNodeActivity24h(now, start) + atStart.add(&StoreTx{FirstSeen: start.Format(time.RFC3339Nano)}) + if atStart.Buckets[0].Count != 1 { + t.Fatalf("packet at the window start must land in bucket 0, got %d", atStart.Buckets[0].Count) + } + + // A stamp one second before the window start is older than the window and + // must be dropped (exercises the pre-filter's skip path). + before := newNodeActivity24h(now, start) + before.add(&StoreTx{FirstSeen: start.Add(-time.Second).Format(time.RFC3339Nano)}) + var total int + for _, b := range before.Buckets { + total += b.Count + } + if total != 0 { + t.Fatalf("pre-window packet must not be counted, got total %d", total) + } +} + +// Test351F1StartupCoverageHonoursRetentionWindow covers #351 F1: the startup +// coverage ratio must be measured against the rows LoadChunked actually +// targeted (the retained window), not a raw COUNT(*) over the whole table. +// With the shipped default (retentionHours>0, hotStartupHours=0) and a DB +// holding history older than the retention window, the old whole-table +// denominator held loadCoverageRatio structurally below 1 forever, so the +// 24h activity gate could never prove completeness and the chart showed +// "Activity unavailable" permanently. Both startup paths are exercised. +func Test351F1StartupCoverageHonoursRetentionWindow(t *testing.T) { + now := time.Now().UTC() + windowStart := now.Add(-24 * time.Hour) + + // 30 daily rows: id 1 = today ... id 30 = 29 days ago. Only the first + // ~8 fall inside a 168h (7-day) retention window; the whole table is 30, + // so a whole-table ratio would be ~0.27. + seed := func(dbPath string) { + seedTestDBRows(t, dbPath, 30, 1, func(i int) (string, int64) { + ts := now.Add(-time.Duration(i-1) * 24 * time.Hour) + return ts.Format(time.RFC3339), ts.Unix() + }) + } + + assertComplete := func(t *testing.T, store *PacketStore) { + t.Helper() + if store.loadCoverageRatio < 1 { + t.Fatalf("loadCoverageRatio=%.4f < 1: coverage measured against the whole table "+ + "instead of the retained window (finding 1)", store.loadCoverageRatio) + } + store.mu.RLock() + cov := store.nodeActivityCoverageStartLocked(now, store.loadCoverageRatio) + store.mu.RUnlock() + if cov.IsZero() || cov.After(windowStart) { + t.Fatalf("24h coverage gate must prove completeness when 7+ days of retained "+ + "history are in memory; got coverageStart=%v (windowStart=%v)", cov, windowStart) + } + } + + t.Run("hotStartupHours=0 synchronous full load", func(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + seed(dbPath) + db, err := OpenDB(dbPath) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + defer db.conn.Close() + store := NewPacketStore(db, &PacketStoreConfig{RetentionHours: 168, HotStartupHours: 0}) + if err := store.RunStartupLoad(500); err != nil { + t.Fatalf("RunStartupLoad: %v", err) + } + assertComplete(t, store) + }) + + t.Run("hotStartupHours=24 hot window plus background fill", func(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + seed(dbPath) + db, err := OpenDB(dbPath) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + defer db.conn.Close() + store := NewPacketStore(db, &PacketStoreConfig{RetentionHours: 168, HotStartupHours: 24}) + if err := store.RunStartupLoad(500); err != nil { + t.Fatalf("RunStartupLoad: %v", err) + } + assertComplete(t, store) + }) +} + func BenchmarkNodeActivity24h30K(b *testing.B) { now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) packets := make([]*StoreTx, 30000) diff --git a/cmd/server/node_liveness_regression_test.go b/cmd/server/node_liveness_regression_test.go index d5291f3eb..d9d847643 100644 --- a/cmd/server/node_liveness_regression_test.go +++ b/cmd/server/node_liveness_regression_test.go @@ -32,7 +32,7 @@ func TestNodeHealth_LastAdvertIsOwnAdvertNotTraffic(t *testing.T) { packets = append([]*StoreTx{advert}, packets...) } store.byNode[key] = packets - health, err := store.GetNodeHealth(key) + health, err := store.GetNodeHealth(key, time.Now().UTC()) if err != nil { t.Fatal(err) } @@ -217,7 +217,7 @@ func snapshotNodeActivity(t *testing.T, store *PacketStore, key string) activity bulkRelay = RepeaterRelayInfo{WindowHours: 24} } snap.bulkRelay = bulkRelay - health, err := store.GetNodeHealth(key) + health, err := store.GetNodeHealth(key, time.Now().UTC()) if err != nil || health == nil { t.Fatalf("health for %s: %v %v", key, health, err) } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 111439f94..093fef64f 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2102,7 +2102,7 @@ func (s *Server) handleNodeHealth(w http.ResponseWriter, r *http.Request) { return } if s.store != nil { - result, err := s.store.GetNodeHealth(pubkey) + result, err := s.store.GetNodeHealth(pubkey, time.Now().UTC()) if err != nil || result == nil { writeError(w, 404, "Not found") return diff --git a/cmd/server/store.go b/cmd/server/store.go index 49bdc4037..42c21c659 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -543,6 +543,19 @@ type PacketStore struct { // perf payload for prod observability. loadCoverageRatio float64 + // loadWindowTotalInDB: row count LoadChunked actually targeted — i.e. + // COUNT(*) honouring the SAME retention/hot-start filter the chunk loop + // applied, not the whole table. #351 F1: RunStartupLoad's + // hotStartupHours==0 branch divides loadedCount by THIS (not the + // unfiltered table count) so loadCoverageRatio means "fraction of the + // RETAINED rows that are in memory", matching docs/api-spec.md and the + // bg-loader path. -1 when the count could not be taken. + // CONCURRENCY: written once by LoadChunked and read once by + // RunStartupLoad on the SAME goroutine (RunStartupLoad calls LoadChunked + // synchronously, then reads this after it returns), so no lock is + // needed; no other goroutine touches it. + loadWindowTotalInDB int64 + // Async hash migration state: set after migrateContentHashesAsync completes. hashMigrationComplete atomic.Bool @@ -10449,11 +10462,10 @@ func (s *PacketStore) GetBulkHealth(limit int, region, area string) []map[string // --- Subpaths Analytics --- // GetNodeHealth returns health info for a single node using in-memory data. -func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, error) { - return s.getNodeHealthAt(pubkey, time.Now().UTC()) -} - -func (s *PacketStore) getNodeHealthAt(pubkey string, now time.Time) (map[string]interface{}, error) { +// #351 F5: now is injected so tests can pin the rolling 24h activity window +// without a second untyped-map-returning helper (AGENTS.md: the untyped-payload +// count must not grow). Production callers pass time.Now().UTC(); see routes.go. +func (s *PacketStore) GetNodeHealth(pubkey string, now time.Time) (map[string]interface{}, error) { // Fetch node info from DB (fast single-row lookup) node, err := s.db.GetNodeByPubkey(pubkey) if err != nil { @@ -10476,13 +10488,20 @@ func (s *PacketStore) getNodeHealthAt(pubkey string, now time.Time) (map[string] } } + // #351 F4: snapshot the bgErrMu-guarded coverage ratio BEFORE taking + // s.mu so this reader honours bgErrMu's "no s.mu needed" contract + // (store.go:530) instead of being the first site to nest s.mu → bgErrMu. + s.bgErrMu.RLock() + loadCoverage := s.loadCoverageRatio + s.bgErrMu.RUnlock() + s.mu.RLock() defer s.mu.RUnlock() packets := s.byNode[pubkey] activityKey := strings.ToLower(pubkey) todayStart := now.UTC().Truncate(24 * time.Hour).Format(time.RFC3339) - activity := newNodeActivity24h(now, s.nodeActivityCoverageStartLocked(now)) + activity := newNodeActivity24h(now, s.nodeActivityCoverageStartLocked(now, loadCoverage)) var packetsToday int var snrSum float64 From 43ba651e1b30194bfdc4a15db6a4525dc847c563 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 7 Oct 2026 19:45:49 +0200 Subject: [PATCH 5/9] fix(home): keep observer names recoverable on every My Mesh card (#351 review F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F2 (blocking): .mnc-observer-name clips at 14ch, but the full name was only recoverable on repeater cards with >3 observers (the View all dialog). On a repeater with 1-3 observers, and on every non-repeater card, a long name was clipped with no tooltip and no disclosure — the name was lost. #304 requires the full list reachable "without losing names". Each observer span now carries a title with the full name (pointer + assistive tech), and the accessible dialog is offered whenever the preview hides names OR any shown name is clipped — on all card roles, not just repeaters with >3. E2E: mixed card roles (repeater with 51/1/0 observers, a <=3-observer repeater with a clipped name, and a non-repeater with a clipped name) at 1280x900 and 375x812; assert title present, name clipped, dialog offered, and the full name recoverable via the dialog, with cards staying compact. Kills the mutant that restores the old "clipped with no recourse" behavior. Relates to #304 --- public/home.js | 14 ++++++++- test-issue-304-my-mesh-e2e.js | 55 ++++++++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/public/home.js b/public/home.js index dc5546c2b..75b839a11 100644 --- a/public/home.js +++ b/public/home.js @@ -319,6 +319,18 @@ const sparkHtml = buildSparkline(h.activity24h); const isRepeater = String(node.role || '').toLowerCase() === 'repeater'; const preview = isRepeater ? obs.slice(0, 3) : obs; + // #351 F2: a name wider than the 14ch clamp is clipped with an + // ellipsis. The full name must stay recoverable on EVERY card — not + // only repeaters with >3 observers. Each span carries a title (pointer + // + assistive tech), and the accessible dialog is offered (keyboard + + // touch) whenever the preview hides names (more observers than shown) + // or any shown name is clipped. 14 ≈ the CSS max-width: 14ch. + const OBS_CLAMP_CH = 14; + const obsFullName = o => o.observer_name || o.observer_id || 'Unknown'; + const hiddenObservers = obs.length - preview.length; + const previewClipped = preview.some(o => obsFullName(o).length > OBS_CLAMP_CH); + const showObserverDialog = hiddenObservers > 0 || previewClipped; + const observerBtnText = hiddenObservers > 0 ? `View all ${obs.length} →` : 'Full names →'; return `
@@ -346,7 +358,7 @@
Avg hops
- ${obs.length ? `
Heard by: ${preview.map(o => `${escapeHtml(o.observer_name || o.observer_id || 'Unknown')}`).join(', ')}${isRepeater && obs.length > 3 ? ` ` : ''}
` : ''} + ${obs.length ? `
Heard by: ${preview.map(o => `${escapeHtml(obsFullName(o))}`).join(', ')}${showObserverDialog ? ` ` : ''}
` : ''}
${sparkHtml}
diff --git a/test-issue-304-my-mesh-e2e.js b/test-issue-304-my-mesh-e2e.js index cba798866..511acee6f 100644 --- a/test-issue-304-my-mesh-e2e.js +++ b/test-issue-304-my-mesh-e2e.js @@ -7,7 +7,9 @@ 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', 'b', 'c', 'd', 'e'].map(c => c.repeat(64)); +// #351 F2: a name wider than the 14ch clamp. Matches the review's repro name. +const CLIP = 'Jackrabbit Mountain Relay North'; const now = Date.now(); const end = new Date(now).toISOString(); const start = new Date(now - 24 * 3600000).toISOString(); @@ -20,8 +22,8 @@ const activity = (complete = true) => ({ })), }); -async function render(page, responses, width) { - await page.setViewportSize({ width, height: 800 }); +async function render(page, responses, width, height) { + await page.setViewportSize({ width, height }); await page.route('**/*', route => { const u = new URL(route.request().url()); if (u.origin !== ORIGIN) return route.abort(); @@ -52,20 +54,29 @@ async function render(page, responses, width) { (async () => { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); try { - for (const width of [1280, 390]) { - const context = await browser.newContext({ viewport: { width, height: 800 }, hasTouch: width < 500, isMobile: width < 500 }); + for (const [width, height] of [[1280, 900], [375, 812]]) { + const context = await browser.newContext({ viewport: { width, height }, hasTouch: width < 500, isMobile: width < 500 }); const page = await context.newPage(); const errors = []; page.on('pageerror', e => errors.push(e.message)); 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`, { - 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, + // 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. + 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: [{ observer_name: CLIP }], activity: activity(false) }, + { role: 'client', observers: [{ observer_name: CLIP }, { observer_name: 'Shorty' }], activity: activity(false) }, + ]; + const data = Object.fromEntries(specs.map((spec, i) => [`/nodes/${KEYS[i]}/health`, { + node: { name: `Node ${i}`, role: spec.role }, stats: { lastHeard: end, packetsToday: 2 }, + observers: spec.observers, recentPackets: [], activity24h: spec.activity, }])); - await render(page, data, width); + await render(page, data, width, height); if (process.env.SCREENSHOT_PATH && width === 1280) { await page.screenshot({ path: process.env.SCREENSHOT_PATH, fullPage: true }); } @@ -73,6 +84,7 @@ async function render(page, responses, width) { const large = cards.nth(0); assert.equal(await large.locator('.mnc-observers .mnc-observer-name').count(), 3); assert.equal(await large.locator('.mnc-observers .mnc-observer-name').nth(1).evaluate(el => el.scrollWidth > el.clientWidth), true); + assert.equal(await large.locator('.mnc-observers .mnc-observer-name').nth(1).getAttribute('title'), names[1]); assert.equal(await large.locator('.mnc-observers svg').count(), 0); assert.equal(await cards.nth(1).locator('.mnc-view-all').count(), 0); assert.equal(await cards.nth(2).locator('.mnc-observers').count(), 0); @@ -109,6 +121,29 @@ async function render(page, responses, width) { await dialog.waitFor({ state: 'visible' }); await dialog.locator('.mnc-dialog-close').tap(); } + + // #351 F2: clipped names stay recoverable on EVERY card — a ≤3-observer + // repeater (card 3) and a NON-repeater (card 4), not only repeaters + // with >3 observers. Title tooltip (pointer/AT) + accessible dialog + // (keyboard/touch), and the cards stay compact. + for (const idx of [3, 4]) { + const card = cards.nth(idx); + const span = card.locator('.mnc-observer-name').first(); + assert.equal(await span.evaluate(el => el.scrollWidth > el.clientWidth), true, `card ${idx} name must clip at ${width}px`); + assert.equal(await span.getAttribute('title'), CLIP, `card ${idx} span must carry the full name in title`); + assert.equal(await card.locator('.mnc-view-all').count(), 1, `card ${idx} must offer the observer dialog`); + } + assert.equal(await cards.nth(4).locator('.mnc-observer-name').count(), 2); + const clipBtn = cards.nth(4).locator('.mnc-view-all'); + await clipBtn.scrollIntoViewIfNeeded(); + await clipBtn.click(); + await dialog.waitFor({ state: 'visible' }); + assert((await dialog.locator('li').allInnerTexts()).includes(CLIP), 'dialog must list the full clipped name'); + await page.keyboard.press('Escape'); + assert.equal(await dialog.isVisible(), false); + const h = await cards.evaluateAll(els => els.map(el => el.getBoundingClientRect().height)); + assert(h[3] < 350 && h[4] < 350, `clipped cards too tall: ${h[3]}, ${h[4]} at ${width}px`); + assert.deepEqual(errors, []); await context.close(); } From 7e573d212c9b86a568e2088e2f5b454718053bf6 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 8 Oct 2026 05:01:21 +0000 Subject: [PATCH 6/9] test(home): reproduce short-but-wide clipped observer names; pin quote escaping (#351 review R1, R2) R1: the observer clamp is CSS max-width: 14ch, so 14-char uppercase names (WWWWWWWWWWWWWW, MOUNT WOLFHAWK) clip without the dialog button. Assert on every card that the button is visible exactly when the preview hides observers or a span renders clipped (scrollWidth > clientWidth), and that a touch tap on 375x812 opens the full name. R2: an observer name containing a double quote must stay inside the title attribute, which kills the review's mutant O9 (escapeAttr dropped). Co-Authored-By: Claude Opus 5.5 --- test-issue-304-my-mesh-e2e.js | 53 +++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/test-issue-304-my-mesh-e2e.js b/test-issue-304-my-mesh-e2e.js index 511acee6f..cccca3f78 100644 --- a/test-issue-304-my-mesh-e2e.js +++ b/test-issue-304-my-mesh-e2e.js @@ -7,9 +7,17 @@ const { chromium } = require('playwright'); const ROOT = path.join(__dirname, 'public'); const ORIGIN = 'http://127.0.0.1:18740'; -const KEYS = ['a', 'b', 'c', 'd', 'e'].map(c => c.repeat(64)); +const KEYS = ['a', 'b', 'c', 'd', 'e', 'f', '1', '2'].map(c => c.repeat(64)); // #351 F2: a name wider than the 14ch clamp. Matches the review's repro name. const CLIP = 'Jackrabbit Mountain Relay North'; +// #351 R1: short (≤14 chars) but WIDE names. The clamp is CSS max-width: 14ch +// (fourteen "0" glyphs), so uppercase/wide glyphs clip well before 14 chars. +// WIDE clips in any sans font; the others are font-dependent and are checked +// by the measured invariant below rather than asserted to clip. +const WIDE = 'WWWWWWWWWWWWWW'; +const SHORT_WIDE = ['MOUNT WOLFHAWK', 'MMMMMMMMMMMMM', 'ØSTERBRO MOLE']; +// #351 R2: a quote breaks out of an attribute that is not escapeAttr'd. +const QUOTE = 'x" onmouseover="alert(1)'; const now = Date.now(); const end = new Date(now).toISOString(); const start = new Date(now - 24 * 3600000).toISOString(); @@ -60,6 +68,7 @@ async function render(page, responses, width, height) { const errors = []; page.on('pageerror', e => errors.push(e.message)); const names = Array.from({ length: 51 }, (_, i) => i === 50 ? '' : `Observer ${i}`); + names[0] = QUOTE; names[1] = 'Very-long-observer-name-'.repeat(25); names[2] = ''; // Card roles are mixed (#304): a 51-observer repeater, a 1-observer @@ -71,6 +80,9 @@ async function render(page, responses, width, height) { { role: 'repeater', observers: [], activity: undefined }, { 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`, { node: { name: `Node ${i}`, role: spec.role }, stats: { lastHeard: end, packetsToday: 2 }, @@ -86,7 +98,7 @@ async function render(page, responses, width, height) { assert.equal(await large.locator('.mnc-observers .mnc-observer-name').nth(1).evaluate(el => el.scrollWidth > el.clientWidth), true); assert.equal(await large.locator('.mnc-observers .mnc-observer-name').nth(1).getAttribute('title'), names[1]); assert.equal(await large.locator('.mnc-observers svg').count(), 0); - assert.equal(await cards.nth(1).locator('.mnc-view-all').count(), 0); + assert.equal(await cards.nth(1).locator('.mnc-view-all:visible').count(), 0); assert.equal(await cards.nth(2).locator('.mnc-observers').count(), 0); assert.equal(await large.locator('.home-spark-bar').count(), 24); assert.match(await large.locator('.mnc-spark').innerText(), /Node-associated transmissions.*last 24h/); @@ -131,7 +143,7 @@ async function render(page, responses, width, height) { const span = card.locator('.mnc-observer-name').first(); assert.equal(await span.evaluate(el => el.scrollWidth > el.clientWidth), true, `card ${idx} name must clip at ${width}px`); assert.equal(await span.getAttribute('title'), CLIP, `card ${idx} span must carry the full name in title`); - assert.equal(await card.locator('.mnc-view-all').count(), 1, `card ${idx} must offer the observer dialog`); + assert.equal(await card.locator('.mnc-view-all:visible').count(), 1, `card ${idx} must offer the observer dialog`); } assert.equal(await cards.nth(4).locator('.mnc-observer-name').count(), 2); const clipBtn = cards.nth(4).locator('.mnc-view-all'); @@ -144,6 +156,41 @@ async function render(page, responses, width, height) { const h = await cards.evaluateAll(els => els.map(el => el.getBoundingClientRect().height)); assert(h[3] < 350 && h[4] < 350, `clipped cards too tall: ${h[3]}, ${h[4]} at ${width}px`); + // #351 R1: "clipped" is what the browser rendered, not a character + // count. On EVERY card the dialog button is visible exactly when the + // preview hides observers or some shown name is visually clipped + // (scrollWidth > clientWidth: the case where the ellipsis is drawn). + const disclosure = await cards.evaluateAll(els => els.map(card => { + const btn = card.querySelector('.mnc-view-all'); + return { + clipped: [...card.querySelectorAll('.mnc-observer-name')].filter(s => s.scrollWidth > s.clientWidth).map(s => s.textContent), + visible: !!btn && btn.checkVisibility(), + }; + })); + disclosure.forEach((d, idx) => { + const hides = specs[idx].role === 'repeater' && specs[idx].observers.length > 3; + assert.equal(d.visible, hides || d.clipped.length > 0, + `card ${idx} at ${width}px: clipped=${JSON.stringify(d.clipped)} hides=${hides} but button visible=${d.visible}`); + }); + assert.deepEqual(disclosure[5].clipped, [WIDE], `a 14-char wide name must clip at ${width}px`); + const wideBtn = cards.nth(5).locator('.mnc-view-all'); + await wideBtn.scrollIntoViewIfNeeded(); + if (width < 500) await wideBtn.tap(); else await wideBtn.click(); + await dialog.waitFor({ state: 'visible' }); + assert.deepEqual(await dialog.locator('li').allInnerTexts(), [WIDE], 'dialog must list the full short-but-wide name'); + await page.keyboard.press('Escape'); + assert.equal(await dialog.isVisible(), false); + + // #351 R2: a name with a double quote must stay inside the title + // attribute — no injected attributes on any observer span. + const attrs = await page.locator('.mnc-observer-name').evaluateAll(els => els.map(el => ({ names: el.getAttributeNames().sort(), title: el.title, text: el.textContent }))); + attrs.forEach(a => { + assert.deepEqual(a.names, ['class', 'title'], `observer span gained attributes: ${a.names}`); + assert.equal(a.title, a.text, 'title must carry the full, unaltered name'); + }); + assert.equal(attrs.filter(a => a.title === QUOTE).length, 1, 'quoted name must render verbatim'); + assert.equal(await page.locator('[onmouseover]').count(), 0); + assert.deepEqual(errors, []); await context.close(); } From b8a626359e8d1c65dbc99ba80a72bd15865971bf Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 8 Oct 2026 05:01:21 +0000 Subject: [PATCH 7/9] fix(home): measure observer-name clipping after layout (#351 review R1) The dialog button was gated on a character count (length > 14), but the clamp is glyph width. Render the button hidden when the preview hides no observers and reveal it after layout on any card whose name actually clips (scrollWidth > clientWidth, which is when the ellipsis is drawn). All reads happen before the writes, so the grid costs one layout pass. Co-Authored-By: Claude Opus 5.5 --- public/home.js | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/public/home.js b/public/home.js index 75b839a11..3c6269f3b 100644 --- a/public/home.js +++ b/public/home.js @@ -319,17 +319,15 @@ const sparkHtml = buildSparkline(h.activity24h); const isRepeater = String(node.role || '').toLowerCase() === 'repeater'; const preview = isRepeater ? obs.slice(0, 3) : obs; - // #351 F2: a name wider than the 14ch clamp is clipped with an - // ellipsis. The full name must stay recoverable on EVERY card — not - // only repeaters with >3 observers. Each span carries a title (pointer - // + assistive tech), and the accessible dialog is offered (keyboard + - // touch) whenever the preview hides names (more observers than shown) - // or any shown name is clipped. 14 ≈ the CSS max-width: 14ch. - const OBS_CLAMP_CH = 14; + // #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), + // and the accessible dialog (keyboard + touch) is offered when the + // preview hides names. Otherwise the button starts hidden and + // revealClippedObserverButtons() shows it after layout if a name is + // actually clipped — glyph width, not character count, decides that. const obsFullName = o => o.observer_name || o.observer_id || 'Unknown'; const hiddenObservers = obs.length - preview.length; - const previewClipped = preview.some(o => obsFullName(o).length > OBS_CLAMP_CH); - const showObserverDialog = hiddenObservers > 0 || previewClipped; const observerBtnText = hiddenObservers > 0 ? `View all ${obs.length} →` : 'Full names →'; return `
@@ -358,7 +356,7 @@
Avg hops
- ${obs.length ? `
Heard by: ${preview.map(o => `${escapeHtml(obsFullName(o))}`).join(', ')}${showObserverDialog ? ` ` : ''}
` : ''} + ${obs.length ? `
Heard by: ${preview.map(o => `${escapeHtml(obsFullName(o))}`).join(', ')}
` : ''}
${sparkHtml}
@@ -387,6 +385,7 @@ })); grid.innerHTML = cards.join(''); + revealClippedObserverButtons(grid); // Wire up remove buttons grid.querySelectorAll('.mnc-remove').forEach(btn => { @@ -430,6 +429,15 @@ }); } + // #351 R1: measure after layout. scrollWidth > clientWidth is exactly when + // the ellipsis is drawn. All reads happen before any write, so the whole + // grid costs one layout pass. + function revealClippedObserverButtons(grid) { + const clipped = [...grid.querySelectorAll('.mnc-view-all[hidden]')].filter(btn => + [...btn.parentElement.querySelectorAll('.mnc-observer-name')].some(el => el.scrollWidth > el.clientWidth)); + clipped.forEach(btn => { btn.hidden = false; }); + } + function showObservers(trigger, observers) { const dialog = document.getElementById('homeObserversDialog'); if (!dialog) return; From 073783e26da68963048cfbacbc3e1f9ecdbd5ea1 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 8 Oct 2026 05:01:21 +0000 Subject: [PATCH 8/9] test(server): reproduce the 24h pre-filter dropping in-window stamps (#351 review R3) RFC3339Nano trims trailing zeros, so WindowStart ("...:00Z", ".5Z") can carry fewer fraction digits than a stamp (".100Z", ".501Z") and the string pre-filter sorts an in-window packet below the bound. Co-Authored-By: Claude Opus 5.5 --- cmd/server/node_activity_304_test.go | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/cmd/server/node_activity_304_test.go b/cmd/server/node_activity_304_test.go index 40567bc42..8820698b5 100644 --- a/cmd/server/node_activity_304_test.go +++ b/cmd/server/node_activity_304_test.go @@ -220,6 +220,46 @@ func TestNodeActivity24hAddPreFilterKeepsBoundaryPackets(t *testing.T) { } } +// TestNodeActivity24hPreFilterIgnoresFractionWidth covers #351 R3: +// RFC3339Nano trims trailing zeros, so WindowStart can carry fewer fraction +// digits than a stamp ("…:00Z" vs the ingestor's "…:00.100Z"), and a plain +// string compare then sorts an in-window stamp below the bound. The +// pre-filter must keep every in-window packet whatever the digit counts. +func TestNodeActivity24hPreFilterIgnoresFractionWidth(t *testing.T) { + cases := []struct { + name string + nanos int + offset time.Duration + layout string + }{ + {"whole-second now, ms stamp", 0, 100 * time.Millisecond, "2006-01-02T15:04:05.000Z07:00"}, + {"whole-second now, nano stamp", 0, time.Nanosecond, time.RFC3339Nano}, + {"trimmed .5 now, ms stamp", 500_000_000, time.Millisecond, "2006-01-02T15:04:05.000Z07:00"}, + {"trimmed .5 now, us stamp", 500_000_000, time.Microsecond, "2006-01-02T15:04:05.000000Z07:00"}, + {"exact start, ms stamp", 500_000_000, 0, "2006-01-02T15:04:05.000Z07:00"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, tc.nanos, time.UTC) + start := now.Add(-24 * time.Hour) + a := newNodeActivity24h(now, start) + a.add(&StoreTx{FirstSeen: start.Add(tc.offset).Format(tc.layout)}) + if a.Buckets[0].Count != 1 { + t.Fatalf("WindowStart=%s, stamp=%s: in-window packet dropped (bucket 0 = %d)", + a.WindowStart, start.Add(tc.offset).Format(tc.layout), a.Buckets[0].Count) + } + // One millisecond before the window must still be dropped. + b := newNodeActivity24h(now, start) + b.add(&StoreTx{FirstSeen: start.Add(-time.Millisecond).Format("2006-01-02T15:04:05.000Z07:00")}) + for i, bk := range b.Buckets { + if bk.Count != 0 { + t.Fatalf("pre-window packet counted in bucket %d", i) + } + } + }) + } +} + // Test351F1StartupCoverageHonoursRetentionWindow covers #351 F1: the startup // coverage ratio must be measured against the rows LoadChunked actually // targeted (the retained window), not a raw COUNT(*) over the whole table. From d971bbd3dce63734a90edb9a94693070d44e4846 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 8 Oct 2026 05:01:21 +0000 Subject: [PATCH 9/9] fix(server): fraction-width-safe 24h pre-filter; fix stale comment (#351 review R3, R4) Compare against the window start truncated to whole seconds in a fixed width layout. A UTC stamp at or after the start always sorts at or above it whatever its fraction, and the start's own second falls through to the exact time check. Also name the caller GetNodeHealth in the lock-order comment instead of the removed getNodeHealthAt. Co-Authored-By: Claude Opus 5.5 --- cmd/server/node_activity_24h.go | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/cmd/server/node_activity_24h.go b/cmd/server/node_activity_24h.go index 1789f2744..23234c222 100644 --- a/cmd/server/node_activity_24h.go +++ b/cmd/server/node_activity_24h.go @@ -19,8 +19,14 @@ type NodeActivity24h struct { Complete bool `json:"complete"` start time.Time end time.Time + // startSecond is start truncated to whole seconds in the fixed-width + // "2006-01-02T15:04:05" form; add() uses it as a string pre-filter. + startSecond string } +// nodeActivitySecondLayout is fixed width: no fraction, no zone suffix. +const nodeActivitySecondLayout = "2006-01-02T15:04:05" + func newNodeActivity24h(now, coverageStart time.Time) NodeActivity24h { end := now.UTC() start := end.Add(-24 * time.Hour) @@ -29,6 +35,7 @@ func newNodeActivity24h(now, coverageStart time.Time) NodeActivity24h { WindowEnd: end.Format(time.RFC3339Nano), start: start, end: end, + startSecond: start.Format(nodeActivitySecondLayout), } for i := range a.Buckets { bucketStart := start.Add(time.Duration(i) * time.Hour) @@ -50,18 +57,18 @@ func (a *NodeActivity24h) add(tx *StoreTx) { if tx == nil { return } - // #351 F6: cheap pre-filter before the RFC3339Nano parse. WindowStart is - // the window lower bound at full nanosecond precision, so any FirstSeen - // that sorts strictly below it is older than the window and is dropped - // without parsing — the common case for a node whose in-memory history - // spans well past 24h. A same-instant stamp at lower precision sorts - // >= WindowStart (its shorter string compares greater once WindowStart's - // fraction digits run out), so an in-window packet is never skipped here; - // the exact bounds check below still runs for everything that survives. - // The upper bound is deliberately NOT string-filtered: WindowEnd carries - // now's sub-second precision, so a whole-second stamp inside the final - // second would sort after it and be dropped incorrectly. - if tx.FirstSeen < a.WindowStart { + // #351 F6/R3: cheap pre-filter before the RFC3339Nano parse, for the + // common case of a node whose in-memory history spans well past 24h. + // It compares against startSecond, the window start truncated to whole + // seconds, NOT WindowStart: RFC3339Nano trims trailing zeros, so the + // fraction widths of WindowStart and a stamp differ ("…:00Z" vs + // "…:00.100Z") and a full-string compare can sort an in-window stamp + // below the bound. The second prefix is fixed width, so a UTC stamp at + // or after the window start always sorts >= startSecond whatever its + // fraction. A stamp in the start's own second survives and the exact + // bounds check below decides it. The upper bound is deliberately NOT + // string-filtered, for the same fraction-width reason. + if tx.FirstSeen < a.startSecond { return } when, err := time.Parse(time.RFC3339Nano, tx.FirstSeen) @@ -77,7 +84,7 @@ func (a *NodeActivity24h) add(tx *StoreTx) { // can evict the oldest rows after oldestLoaded was set, so use the current // first row as the stricter lower bound when it is enabled. // -// #351 F4: loadCoverage is read by the caller (getNodeHealthAt) BEFORE it +// #351 F4: loadCoverage is read by the caller (GetNodeHealth) BEFORE it // takes s.mu and passed in here, so this function never acquires bgErrMu // while holding s.mu. bgErrMu exists precisely so its readers need not // synchronise on s.mu (store.go:530); nesting s.mu → bgErrMu would be the