diff --git a/cmd/server/coverage_test.go b/cmd/server/coverage_test.go index 7c1e1df..40d5ce7 100644 --- a/cmd/server/coverage_test.go +++ b/cmd/server/coverage_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "database/sql" "encoding/json" "fmt" @@ -4232,3 +4233,67 @@ func TestDistanceIncrementalUpdate(t *testing.T) { t.Logf("Distance index: %d→%d hops, %d→%d paths (incremental)", initialHops, len(store.distHops), initialPaths, len(store.distPaths)) } + +func TestHandleBatchObservations(t *testing.T) { + _, router := setupNoStoreServer(t) + + t.Run("empty hashes returns empty results", func(t *testing.T) { + body := strings.NewReader(`{"hashes":[]}`) + req := httptest.NewRequest("POST", "/api/packets/observations", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + results, ok := resp["results"].(map[string]interface{}) + if !ok || len(results) != 0 { + t.Fatalf("expected empty results map, got %v", resp) + } + }) + + t.Run("invalid JSON returns 400", func(t *testing.T) { + body := strings.NewReader(`not json`) + req := httptest.NewRequest("POST", "/api/packets/observations", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 400 { + t.Fatalf("expected 400, got %d", w.Code) + } + }) + + t.Run("too many hashes returns 400", func(t *testing.T) { + hashes := make([]string, 201) + for i := range hashes { + hashes[i] = fmt.Sprintf("hash%d", i) + } + data, _ := json.Marshal(map[string][]string{"hashes": hashes}) + req := httptest.NewRequest("POST", "/api/packets/observations", bytes.NewReader(data)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 400 { + t.Fatalf("expected 400, got %d", w.Code) + } + }) + + t.Run("valid hashes with no store returns empty results", func(t *testing.T) { + body := strings.NewReader(`{"hashes":["abc123","def456"]}`) + req := httptest.NewRequest("POST", "/api/packets/observations", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + _, ok := resp["results"].(map[string]interface{}) + if !ok { + t.Fatalf("expected results map, got %v", resp) + } + }) +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 199ef2b..f1e2186 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -118,6 +118,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET") // Packet endpoints + r.HandleFunc("/api/packets/observations", s.handleBatchObservations).Methods("POST") r.HandleFunc("/api/packets/timestamps", s.handlePacketTimestamps).Methods("GET") r.HandleFunc("/api/packets/{id}", s.handlePacketDetail).Methods("GET") r.HandleFunc("/api/packets", s.handlePackets).Methods("GET") @@ -791,6 +792,38 @@ var muxBraceParam = regexp.MustCompile(`\{([^}]+)\}`) // perfHexFallback matches hex IDs for perf path normalization fallback. var perfHexFallback = regexp.MustCompile(`[0-9a-f]{8,}`) +// handleBatchObservations returns observations for multiple hashes in a single request. +// POST /api/packets/observations with JSON body: {"hashes": ["abc123", "def456", ...]} +// Response: {"results": {"abc123": [...observations...], "def456": [...], ...}} +// Limited to 200 hashes per request to prevent abuse. +func (s *Server) handleBatchObservations(w http.ResponseWriter, r *http.Request) { + var body struct { + Hashes []string `json:"hashes"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, 400, "invalid JSON body") + return + } + const maxHashes = 200 + if len(body.Hashes) > maxHashes { + writeError(w, 400, fmt.Sprintf("too many hashes (max %d)", maxHashes)) + return + } + if len(body.Hashes) == 0 { + writeJSON(w, map[string]interface{}{"results": map[string]interface{}{}}) + return + } + + results := make(map[string][]ObservationResp, len(body.Hashes)) + if s.store != nil { + for _, hash := range body.Hashes { + obs := s.store.GetObservationsForHash(hash) + results[hash] = mapSliceToObservations(obs) + } + } + writeJSON(w, map[string]interface{}{"results": results}) +} + func (s *Server) handlePacketDetail(w http.ResponseWriter, r *http.Request) { param := mux.Vars(r)["id"] var packet map[string]interface{} diff --git a/public/packets.js b/public/packets.js index 0004e29..9df4e92 100644 --- a/public/packets.js +++ b/public/packets.js @@ -889,18 +889,30 @@ obsSortSel.addEventListener('change', async function () { obsSortMode = this.value; localStorage.setItem('meshcore-obs-sort', obsSortMode); - // For non-observer sorts, fetch children for visible groups that don't have them yet + // For non-observer sorts, batch-fetch children for visible groups that don't have them yet if (obsSortMode !== SORT_OBSERVER && groupByHash) { const toFetch = packets.filter(p => p.hash && !p._children && (p.observation_count || 0) > 1); - await Promise.all(toFetch.map(async (p) => { + if (toFetch.length > 0) { + const hashes = toFetch.map(p => p.hash); try { - const data = await api(`/packets/${p.hash}`); - if (data?.packet && data.observations) { - p._children = data.observations.map(o => clearParsedCache({...data.packet, ...o, _isObservation: true})); - p._fetchedData = data; + const resp = await fetch('/api/packets/observations', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({hashes}) + }); + if (resp.ok) { + const data = await resp.json(); + const results = data.results || {}; + for (const p of toFetch) { + const obs = results[p.hash]; + if (obs && obs.length) { + p._children = obs.map(o => clearParsedCache({...p, ...o, _isObservation: true})); + p._fetchedData = {packet: p, observations: obs}; + } + } } } catch {} - })); + } } // Re-sort all groups with children for (const p of packets) {