diff --git a/cmd/server/routes.go b/cmd/server/routes.go index bd9d7fc0..356f0149 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2084,7 +2084,9 @@ func (s *Server) handleAnalyticsSubpaths(w http.ResponseWriter, r *http.Request) } maxLen := queryInt(r, "maxLen", 8) limit := queryLimit(r, 100, 200) - data := s.store.GetAnalyticsSubpaths(region, minLen, maxLen, limit) + // Issue #1217: honor the Time window filter on Route Patterns. + window := ParseTimeWindow(r) + data := s.store.GetAnalyticsSubpathsWithWindow(region, minLen, maxLen, limit, window) if s.cfg != nil && len(s.cfg.NodeBlacklist) > 0 { data = s.filterBlacklistedFromSubpaths(data) } @@ -2145,7 +2147,7 @@ func (s *Server) handleAnalyticsSubpathsBulk(w http.ResponseWriter, r *http.Requ return } - results := s.store.GetAnalyticsSubpathsBulk(region, groups) + results := s.store.GetAnalyticsSubpathsBulkWithWindow(region, groups, ParseTimeWindow(r)) if s.cfg != nil && len(s.cfg.NodeBlacklist) > 0 { for i, r := range results { results[i] = s.filterBlacklistedFromSubpaths(r) diff --git a/cmd/server/store.go b/cmd/server/store.go index 00abf413..126839f7 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -9216,6 +9216,120 @@ func (s *PacketStore) GetNodeAnalytics(pubkey string, days int) (*NodeAnalyticsR }, nil } +// GetAnalyticsSubpathsWithWindow is the window-aware variant of +// GetAnalyticsSubpaths. Issue #1217: the Route Patterns chart on /analytics +// ignored the Time window filter because callers always reached the unbounded +// path. With a zero TimeWindow this is byte-equivalent to GetAnalyticsSubpaths. +// +// For non-zero windows we iterate the packet list and filter on +// `tx.FirstSeen`. We deliberately do not consult the precomputed `spIndex` +// (which has no per-tx timestamp), so the windowed path is O(N_tx · path²); +// this matches the slow region-filtered path and keeps the fast unbounded +// hot path untouched. Results are cached by (region|area|window) so repeated +// renders of the same window don't re-scan. +func (s *PacketStore) GetAnalyticsSubpathsWithWindow(region string, minLen, maxLen, limit int, window TimeWindow) map[string]interface{} { + if window.IsZero() { + return s.GetAnalyticsSubpaths(region, minLen, maxLen, limit) + } + + cacheKey := fmt.Sprintf("%s|%d|%d|%d|w=%s", region, minLen, maxLen, limit, window.CacheKey()) + s.cacheMu.Lock() + if cached, ok := s.subpathCache[cacheKey]; ok && time.Now().Before(cached.expiresAt) { + s.cacheHits++ + s.cacheMu.Unlock() + return cached.data + } + s.cacheMisses++ + s.cacheMu.Unlock() + + result := s.computeAnalyticsSubpathsWindowed(region, minLen, maxLen, limit, window) + + s.cacheMu.Lock() + s.subpathCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)} + s.cacheMu.Unlock() + + return result +} + +// computeAnalyticsSubpathsWindowed iterates s.packets and bounds the result +// to transmissions whose FirstSeen falls inside `window`. Optionally also +// region-filters. Mirrors computeSubpathsSlow but with the window predicate +// inlined and without requiring a non-empty region. +func (s *PacketStore) computeAnalyticsSubpathsWindowed(region string, minLen, maxLen, limit int, window TimeWindow) map[string]interface{} { + s.mu.RLock() + defer s.mu.RUnlock() + + _, pm := s.getCachedNodesAndPM() + contextPubkeys := buildAggregateHopContextPubkeys(s.packets, pm) + hopCache := make(map[string]*nodeInfo) + graph := s.graph.Load() + resolveHop := func(hop string) string { + if cached, ok := hopCache[hop]; ok { + if cached != nil { + return cached.Name + } + return hop + } + r, _, _ := pm.resolveWithContext(hop, contextPubkeys, graph) + hopCache[hop] = r + if r != nil { + return r.Name + } + return hop + } + + var regionObs map[string]bool + if region != "" { + regionObs = s.resolveRegionObservers(region) + } + + subpathCounts := make(map[string]*subpathAccum) + totalPaths := 0 + + for _, tx := range s.packets { + if !window.Includes(tx.FirstSeen) { + continue + } + hops := txGetParsedPath(tx) + if len(hops) < 2 { + continue + } + if regionObs != nil { + match := false + for _, obs := range tx.Observations { + if regionObs[obs.ObserverID] { + match = true + break + } + } + if !match { + continue + } + } + totalPaths++ + + named := make([]string, len(hops)) + for i, h := range hops { + named[i] = resolveHop(h) + } + + for l := minLen; l <= maxLen && l <= len(named); l++ { + for start := 0; start <= len(named)-l; start++ { + sub := strings.Join(named[start:start+l], " → ") + raw := strings.Join(hops[start:start+l], ",") + entry := subpathCounts[sub] + if entry == nil { + entry = &subpathAccum{raw: raw} + subpathCounts[sub] = entry + } + entry.count++ + } + } + } + + return s.rankSubpaths(subpathCounts, totalPaths, limit) +} + func (s *PacketStore) GetAnalyticsSubpaths(region string, minLen, maxLen, limit int) map[string]interface{} { cacheKey := fmt.Sprintf("%s|%d|%d|%d", region, minLen, maxLen, limit) @@ -9237,6 +9351,21 @@ func (s *PacketStore) GetAnalyticsSubpaths(region string, minLen, maxLen, limit return result } +// GetAnalyticsSubpathsBulkWithWindow is the window-aware variant. For a zero +// TimeWindow it is byte-equivalent to GetAnalyticsSubpathsBulk; for a non- +// zero window it delegates to GetAnalyticsSubpathsWithWindow per group so +// the `tx.FirstSeen` filter is honored (issue #1217). +func (s *PacketStore) GetAnalyticsSubpathsBulkWithWindow(region string, groups []subpathGroup, window TimeWindow) []map[string]interface{} { + if window.IsZero() { + return s.GetAnalyticsSubpathsBulk(region, groups) + } + results := make([]map[string]interface{}, len(groups)) + for i, g := range groups { + results[i] = s.GetAnalyticsSubpathsWithWindow(region, g.MinLen, g.MaxLen, g.Limit, window) + } + return results +} + // GetAnalyticsSubpathsBulk returns multiple length-range buckets from a single // scan of the subpath index, avoiding repeated iterations. func (s *PacketStore) GetAnalyticsSubpathsBulk(region string, groups []subpathGroup) []map[string]interface{} { diff --git a/cmd/server/subpaths_window_test.go b/cmd/server/subpaths_window_test.go new file mode 100644 index 00000000..01be95aa --- /dev/null +++ b/cmd/server/subpaths_window_test.go @@ -0,0 +1,160 @@ +package main + +// Regression test for issue #1217 — Route Patterns analytics must honor the +// `?window=` time-window filter (e.g. "1h", "24h", "7d"). Before the fix, +// computeAnalyticsSubpaths read the full s.spIndex / s.packets regardless of +// the window, so the chart counts were identical for every window selection. +// +// This test seeds two transmissions with distinct multi-hop paths at different +// `first_seen` ages (one recent, one ~30 days old) and asserts: +// - the unbounded call returns BOTH paths +// - a 24h windowed call returns ONLY the recent path +// +// If the handler/store ignores the window param, the windowed call returns +// the same totalPaths/subpaths as the unbounded call and the assertion below +// fails — that's the red-commit signal. + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "testing" + "time" +) + +// setupSubpathWindowDB seeds a DB with one recent and one old multi-hop +// transmission, each with a distinct path so they produce distinct subpaths. +func setupSubpathWindowDB(t *testing.T) *DB { + t.Helper() + db := setupTestDB(t) + + now := time.Now().UTC() + recent := now.Add(-1 * time.Hour).Format(time.RFC3339) + old := now.Add(-30 * 24 * time.Hour).Format(time.RFC3339) + recentEpoch := now.Add(-1 * time.Hour).Unix() + oldEpoch := now.Add(-30 * 24 * time.Hour).Unix() + + // Observer + db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) + VALUES ('obs1', 'Observer One', 'SJC', ?, '2025-01-01T00:00:00Z', 100)`, recent) + + // Recent transmission with path ["aa","bb"] + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('01', 'recent_hash_window_001', ?, 1, 4, '{}')`, recent) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 10.0, -90, '["aa","bb"]', ?)`, recentEpoch) + + // Old transmission (30d ago) with disjoint path ["cc","dd"] + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('02', 'old_hash_window_002', ?, 1, 4, '{}')`, old) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (2, 1, 10.0, -90, '["cc","dd"]', ?)`, oldEpoch) + + return db +} + +// TestSubpathsHonorsTimeWindow_StoreLevel asserts that the store-level +// API exposes a window-aware variant and that it filters by first_seen. +func TestSubpathsHonorsTimeWindow_StoreLevel(t *testing.T) { + db := setupSubpathWindowDB(t) + defer db.Close() + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load failed: %v", err) + } + + // Unbounded: should see both transmissions and their subpaths. + all := store.GetAnalyticsSubpathsWithWindow("", 2, 8, 100, TimeWindow{}) + allTotal, _ := all["totalPaths"].(int) + if allTotal != 2 { + t.Fatalf("unbounded: expected totalPaths=2, got %d (subpaths=%v)", allTotal, all["subpaths"]) + } + + // 24h window: should exclude the 30d-old transmission. + since := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339) + w := TimeWindow{Since: since, Label: "24h"} + windowed := store.GetAnalyticsSubpathsWithWindow("", 2, 8, 100, w) + winTotal, _ := windowed["totalPaths"].(int) + if winTotal != 1 { + t.Errorf("windowed (24h): expected totalPaths=1, got %d (subpaths=%v)", winTotal, windowed["subpaths"]) + } + + // And the old path "cc → dd" must NOT appear in the windowed response. + if subs, ok := windowed["subpaths"].([]map[string]interface{}); ok { + for _, s := range subs { + if p, _ := s["path"].(string); p == "cc → dd" { + t.Errorf("windowed (24h) leaked the old path %q", p) + } + } + } +} + +// TestSubpathsHandlerHonorsTimeWindow asserts that the HTTP handler reads +// `?window=` and forwards it to the store. +func TestSubpathsHandlerHonorsTimeWindow(t *testing.T) { + db := setupSubpathWindowDB(t) + defer db.Close() + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load failed: %v", err) + } + srv.store = store + + mustGet := func(url string) map[string]interface{} { + req := httptest.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + switch { + case containsPath(url, "/api/analytics/subpaths-bulk"): + srv.handleAnalyticsSubpathsBulk(w, req) + default: + srv.handleAnalyticsSubpaths(w, req) + } + if w.Code != 200 { + t.Fatalf("GET %s: status=%d body=%s", url, w.Code, w.Body.String()) + } + var out map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("json decode %s: %v body=%s", url, err, w.Body.String()) + } + return out + } + + all := mustGet("/api/analytics/subpaths?minLen=2&maxLen=8") + allTotal, _ := all["totalPaths"].(float64) + if int(allTotal) != 2 { + t.Fatalf("unbounded: expected totalPaths=2, got %v", all["totalPaths"]) + } + + win := mustGet("/api/analytics/subpaths?minLen=2&maxLen=8&window=24h") + winTotal, _ := win["totalPaths"].(float64) + if int(winTotal) != 1 { + t.Errorf("window=24h: expected totalPaths=1, got %v (resp=%+v)", win["totalPaths"], win) + } + + // Bulk endpoint must also honor window. + bulk := mustGet("/api/analytics/subpaths-bulk?groups=2-2:50&window=24h") + results, _ := bulk["results"].([]interface{}) + if len(results) != 1 { + t.Fatalf("bulk: expected 1 result group, got %d", len(results)) + } + r0 := results[0].(map[string]interface{}) + bulkTotal, _ := r0["totalPaths"].(float64) + if int(bulkTotal) != 1 { + t.Errorf("bulk window=24h: expected totalPaths=1, got %v", r0["totalPaths"]) + } +} + +func containsPath(url, want string) bool { + for i := 0; i+len(want) <= len(url); i++ { + if url[i:i+len(want)] == want { + return true + } + } + return false +} + +// silence unused-import for fmt when iterating +var _ = fmt.Sprintf diff --git a/public/analytics.js b/public/analytics.js index d08e486c..5ea72883 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1876,7 +1876,12 @@ el.innerHTML = '
Analyzing route patterns…
'; try { const rq = RegionFilter.regionQueryString(); - const bulk = await api('/analytics/subpaths-bulk?groups=2-2:50,3-3:30,4-4:20,5-8:15' + rq, { ttl: CLIENT_TTL.analyticsRF }); + // Issue #1217: thread the Time window picker into the Route Patterns + // request so the chart actually reflects the user's selection. + const twEl = document.getElementById('analyticsTimeWindow'); + const twVal = twEl ? twEl.value : ''; + const tws = twVal ? '&window=' + encodeURIComponent(twVal) : ''; + const bulk = await api('/analytics/subpaths-bulk?groups=2-2:50,3-3:30,4-4:20,5-8:15' + rq + tws, { ttl: CLIENT_TTL.analyticsRF }); const [d2, d3, d4, d5] = bulk.results; function renderTable(data, title) {