mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-29 02:21:45 +00:00
## What The Route Patterns chart on `/#/analytics` ignored the Time window picker — every selection returned identical data. This PR threads `?window=` through to the backing endpoints and the store-level computation. ## Root cause `cmd/server/routes.go:2065` (`handleAnalyticsSubpaths`) and `cmd/server/routes.go:2090` (`handleAnalyticsSubpathsBulk`) never called `ParseTimeWindow(r)`. The store-level entry points (`GetAnalyticsSubpaths`, `GetAnalyticsSubpathsBulk`) had no window-aware variant. The frontend (`public/analytics.js`) didn't append `&window=` to the `/analytics/subpaths-bulk` request. ## Fix ### Backend (`cmd/server/store.go`) Added `GetAnalyticsSubpathsWithWindow` + `GetAnalyticsSubpathsBulkWithWindow`. Zero `TimeWindow` → byte-equivalent to the existing fast path (no perf regression on the default view). Non-zero window → iterate `s.packets`, filter on `tx.FirstSeen` via `TimeWindow.Includes`, reuse `rankSubpaths`. Cached by `(region|area|window)`. ```diff -data := s.store.GetAnalyticsSubpaths(region, minLen, maxLen, limit) +window := ParseTimeWindow(r) +data := s.store.GetAnalyticsSubpathsWithWindow(region, minLen, maxLen, limit, window) ``` ```diff -results := s.store.GetAnalyticsSubpathsBulk(region, groups) +results := s.store.GetAnalyticsSubpathsBulkWithWindow(region, groups, ParseTimeWindow(r)) ``` ### Frontend (`public/analytics.js`) `renderSubpaths` now appends `&window=<value>` to the `/analytics/subpaths-bulk` request, matching how RF / topology / channels tabs already wire the picker. ## Before / after ``` GET /api/analytics/subpaths?window=24h → totalPaths=2 (all data — ignored window) GET /api/analytics/subpaths?window=24h → totalPaths=1 (24h-bounded — honored) ``` ## Tests `cmd/server/subpaths_window_test.go`: - `TestSubpathsHonorsTimeWindow_StoreLevel` — seeds a 1h-old tx with path `[aa,bb]` + a 30d-old tx with path `[cc,dd]`; asserts the unbounded call sees both and the 24h-windowed call sees only the recent one. - `TestSubpathsHandlerHonorsTimeWindow` — same scenario via the HTTP handlers for `/api/analytics/subpaths` and `/api/analytics/subpaths-bulk`. TDD: red commit `eefc27d3` (test fails on assertion with stub that ignores window), green commit `4c4c45d0` (implementation makes it pass). Full `go test ./...` in `cmd/server` green locally (~47s). ## Performance Default view (no window selected) is unchanged — `window.IsZero()` short-circuits to the existing precomputed-index hot path. Windowed view is O(N_tx · path²), same complexity as the existing region-filtered slow path. Results cached per `(region|area|window)`. Closes #1217 --------- Co-authored-by: Kpa-clawbot <bot@corescope>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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{} {
|
||||
|
||||
@@ -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
|
||||
+6
-1
@@ -1876,7 +1876,12 @@
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user