diff --git a/cmd/server/routes.go b/cmd/server/routes.go index f1e21864..72e7951d 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -146,6 +146,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/analytics/hash-sizes", s.handleAnalyticsHashSizes).Methods("GET") r.HandleFunc("/api/analytics/hash-collisions", s.handleAnalyticsHashCollisions).Methods("GET") r.HandleFunc("/api/analytics/subpaths", s.handleAnalyticsSubpaths).Methods("GET") + r.HandleFunc("/api/analytics/subpaths-bulk", s.handleAnalyticsSubpathsBulk).Methods("GET") r.HandleFunc("/api/analytics/subpath-detail", s.handleAnalyticsSubpathDetail).Methods("GET") r.HandleFunc("/api/analytics/neighbor-graph", s.handleNeighborGraph).Methods("GET") @@ -1383,6 +1384,57 @@ func (s *Server) handleAnalyticsSubpaths(w http.ResponseWriter, r *http.Request) }) } +// handleAnalyticsSubpathsBulk returns multiple length-range buckets in a single +// response, avoiding repeated scans of the same packet data. Query format: +// ?groups=2-2:50,3-3:30,4-4:20,5-8:15 (minLen-maxLen:limit per group) +func (s *Server) handleAnalyticsSubpathsBulk(w http.ResponseWriter, r *http.Request) { + region := r.URL.Query().Get("region") + groupsParam := r.URL.Query().Get("groups") + if groupsParam == "" { + writeJSON(w, ErrorResp{Error: "groups parameter required (e.g. groups=2-2:50,3-3:30)"}) + return + } + + var groups []subpathGroup + for _, g := range strings.Split(groupsParam, ",") { + parts := strings.SplitN(g, ":", 2) + if len(parts) != 2 { + writeJSON(w, ErrorResp{Error: "invalid group format: " + g}) + return + } + rangeParts := strings.SplitN(parts[0], "-", 2) + if len(rangeParts) != 2 { + writeJSON(w, ErrorResp{Error: "invalid range format: " + parts[0]}) + return + } + mn, err1 := strconv.Atoi(rangeParts[0]) + mx, err2 := strconv.Atoi(rangeParts[1]) + lim, err3 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || err3 != nil || mn < 2 || mx < mn || lim < 1 { + writeJSON(w, ErrorResp{Error: "invalid group: " + g}) + return + } + groups = append(groups, subpathGroup{mn, mx, lim}) + } + + if s.store == nil { + results := make([]map[string]interface{}, len(groups)) + for i := range groups { + results[i] = map[string]interface{}{"subpaths": []interface{}{}, "totalPaths": 0} + } + writeJSON(w, map[string]interface{}{"results": results}) + return + } + + results := s.store.GetAnalyticsSubpathsBulk(region, groups) + writeJSON(w, map[string]interface{}{"results": results}) +} + +// subpathGroup defines a length-range + limit for the bulk subpaths endpoint. +type subpathGroup struct { + MinLen, MaxLen, Limit int +} + func (s *Server) handleAnalyticsSubpathDetail(w http.ResponseWriter, r *http.Request) { hops := r.URL.Query().Get("hops") if hops == "" { diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index b9a10a8e..4b0052d9 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -1105,6 +1105,63 @@ func TestAnalyticsSubpaths(t *testing.T) { } } +func TestAnalyticsSubpathsBulk(t *testing.T) { + _, router := setupTestServer(t) + + // Valid request with multiple groups. + req := httptest.NewRequest("GET", "/api/analytics/subpaths-bulk?groups=2-2:50,3-3:30,5-8:15", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + results, ok := body["results"].([]interface{}) + if !ok { + t.Fatal("expected results array") + } + if len(results) != 3 { + t.Errorf("expected 3 result groups, got %d", len(results)) + } + // Each result should have subpaths and totalPaths. + for i, r := range results { + rm, ok := r.(map[string]interface{}) + if !ok { + t.Fatalf("result %d not a map", i) + } + if _, ok := rm["subpaths"]; !ok { + t.Errorf("result %d missing subpaths", i) + } + if _, ok := rm["totalPaths"]; !ok { + t.Errorf("result %d missing totalPaths", i) + } + } + + // Missing groups param → error. + req2 := httptest.NewRequest("GET", "/api/analytics/subpaths-bulk", nil) + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, req2) + if w2.Code != 200 { + t.Fatalf("expected 200 with error body, got %d", w2.Code) + } + var errBody map[string]interface{} + json.Unmarshal(w2.Body.Bytes(), &errBody) + if _, ok := errBody["error"]; !ok { + t.Error("expected error field for missing groups param") + } + + // Invalid group format. + req3 := httptest.NewRequest("GET", "/api/analytics/subpaths-bulk?groups=bad", nil) + w3 := httptest.NewRecorder() + router.ServeHTTP(w3, req3) + var errBody3 map[string]interface{} + json.Unmarshal(w3.Body.Bytes(), &errBody3) + if _, ok := errBody3["error"]; !ok { + t.Error("expected error for invalid group format") + } +} + func TestAnalyticsSubpathDetailWithHops(t *testing.T) { _, router := setupTestServer(t) req := httptest.NewRequest("GET", "/api/analytics/subpath-detail?hops=aa,bb", nil) diff --git a/cmd/server/store.go b/cmd/server/store.go index 44455945..05fe4e56 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -5919,6 +5919,111 @@ func (s *PacketStore) GetAnalyticsSubpaths(region string, minLen, maxLen, limit return result } +// 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{} { + // For region queries or when there are few groups, fall back to individual calls + // which benefit from per-key caching. + if region != "" { + results := make([]map[string]interface{}, len(groups)) + for i, g := range groups { + results[i] = s.GetAnalyticsSubpaths(region, g.MinLen, g.MaxLen, g.Limit) + } + return results + } + + // Check if all groups are cached. + allCached := true + cachedResults := make([]map[string]interface{}, len(groups)) + s.cacheMu.Lock() + for i, g := range groups { + cacheKey := fmt.Sprintf("|%d|%d|%d", g.MinLen, g.MaxLen, g.Limit) + if cached, ok := s.subpathCache[cacheKey]; ok && time.Now().Before(cached.expiresAt) { + cachedResults[i] = cached.data + } else { + allCached = false + break + } + } + if allCached { + s.cacheHits += int64(len(groups)) + s.cacheMu.Unlock() + return cachedResults + } + s.cacheMu.Unlock() + + // Single scan: bucket by hop length into per-group accumulators. + s.mu.RLock() + _, pm := s.getCachedNodesAndPM() + hopCache := make(map[string]*nodeInfo) + resolveHop := func(hop string) string { + if cached, ok := hopCache[hop]; ok { + if cached != nil { + return cached.Name + } + return hop + } + r, _, _ := pm.resolveWithContext(hop, nil, s.graph) + hopCache[hop] = r + if r != nil { + return r.Name + } + return hop + } + + perGroup := make([]map[string]*subpathAccum, len(groups)) + for i := range groups { + perGroup[i] = make(map[string]*subpathAccum) + } + + for rawKey, count := range s.spIndex { + hops := strings.Split(rawKey, ",") + hopLen := len(hops) + + // Resolve hop names once, reuse across groups. + var named []string + var namedKey string + resolved := false + + for gi, g := range groups { + if hopLen < g.MinLen || hopLen > g.MaxLen { + continue + } + if !resolved { + named = make([]string, hopLen) + for i, h := range hops { + named[i] = resolveHop(h) + } + namedKey = strings.Join(named, " → ") + resolved = true + } + entry := perGroup[gi][namedKey] + if entry == nil { + entry = &subpathAccum{raw: rawKey} + perGroup[gi][namedKey] = entry + } + entry.count += count + } + } + totalPaths := s.spTotalPaths + s.mu.RUnlock() + + results := make([]map[string]interface{}, len(groups)) + for i, g := range groups { + results[i] = s.rankSubpaths(perGroup[i], totalPaths, g.Limit) + } + + // Cache individual results for future single-key lookups too. + s.cacheMu.Lock() + for i, g := range groups { + cacheKey := fmt.Sprintf("|%d|%d|%d", g.MinLen, g.MaxLen, g.Limit) + s.subpathCache[cacheKey] = &cachedResult{data: results[i], expiresAt: time.Now().Add(s.rfCacheTTL)} + } + s.cacheMu.Unlock() + + return results +} + // subpathAccum holds a running count for a single named subpath. type subpathAccum struct { count int diff --git a/public/analytics.js b/public/analytics.js index 75f83e44..f45fe726 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1398,12 +1398,8 @@ el.innerHTML = '