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/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 new file mode 100644 index 000000000..23234c222 --- /dev/null +++ b/cmd/server/node_activity_24h.go @@ -0,0 +1,125 @@ +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 + // 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) + a := NodeActivity24h{ + WindowStart: start.Format(time.RFC3339Nano), + 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) + 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 + } + // #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) + 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. +// +// #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 +// 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. + // 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) + 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..8820698b5 --- /dev/null +++ b/cmd/server/node_activity_304_test.go @@ -0,0 +1,349 @@ +package main + +import ( + "encoding/json" + "path/filepath" + "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.GetNodeHealth(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, s.loadCoverageRatio); got.After(start) || got.IsZero() { + t.Fatalf("complete load coverage=%v", got) + } + s.loadCoverageRatio = 0.90 + 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, 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, 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, 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, s.loadCoverageRatio); !got.After(start) { + t.Fatalf("memory cap evicted first hour, coverage=%v", got) + } + s.backgroundLoadFailed.Store(true) + 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, 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, 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) + } +} + +// 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. +// 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) + 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/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 7ad0237ad..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,7 +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) { +// #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 { @@ -10472,12 +10488,20 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro } } + // #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 := 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, loadCoverage)) var packetsToday int var snrSum float64 @@ -10494,6 +10518,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 +10653,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..3c6269f3b 100644 --- a/public/home.js +++ b/public/home.js @@ -115,6 +115,10 @@ ${hasNodes ? '