diff --git a/cmd/server/coverage_test.go b/cmd/server/coverage_test.go index 40d5ce78..3c0d34e9 100644 --- a/cmd/server/coverage_test.go +++ b/cmd/server/coverage_test.go @@ -2034,6 +2034,48 @@ func TestTxToMap(t *testing.T) { } } +func TestTxToMapLazyObservations(t *testing.T) { + snr := 10.5 + rssi := -90.0 + tx := &StoreTx{ + ID: 1, + Hash: "abc", + Observations: []*StoreObs{ + {ID: 10, ObserverID: "obs1", ObserverName: "O1", SNR: &snr, RSSI: &rssi, Timestamp: "2025-01-01"}, + {ID: 11, ObserverID: "obs2", ObserverName: "O2", SNR: &snr, RSSI: &rssi, Timestamp: "2025-01-02"}, + }, + } + + // Without flag: no observations key + m := txToMap(tx) + if _, ok := m["observations"]; ok { + t.Error("txToMap without includeObservations should not include observations key") + } + + // With false: no observations key + m = txToMap(tx, false) + if _, ok := m["observations"]; ok { + t.Error("txToMap(tx, false) should not include observations key") + } + + // With true: observations included + m = txToMap(tx, true) + obs, ok := m["observations"] + if !ok { + t.Fatal("txToMap(tx, true) should include observations key") + } + obsList, ok := obs.([]map[string]interface{}) + if !ok { + t.Fatal("observations should be []map[string]interface{}") + } + if len(obsList) != 2 { + t.Errorf("expected 2 observations, got %d", len(obsList)) + } + if obsList[0]["observer_id"] != "obs1" { + t.Errorf("expected observer_id obs1, got %v", obsList[0]["observer_id"]) + } +} + // --- filterTxSlice --- func TestFilterTxSlice(t *testing.T) { diff --git a/cmd/server/db.go b/cmd/server/db.go index 5ea431a1..6c253d6f 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -377,7 +377,8 @@ type PacketQuery struct { Until string Region string Node string - Order string // ASC or DESC + Order string // ASC or DESC + ExpandObservations bool // when true, include observation sub-maps in txToMap output } // PacketResult wraps paginated packet list. diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 72e7951d..88f86234 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -720,7 +720,8 @@ func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) { Until: r.URL.Query().Get("until"), Region: r.URL.Query().Get("region"), Node: r.URL.Query().Get("node"), - Order: "DESC", + Order: "DESC", + ExpandObservations: r.URL.Query().Get("expand") == "observations", } if r.URL.Query().Get("order") == "asc" { q.Order = "ASC" @@ -762,13 +763,6 @@ func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) { return } - // Strip observations from default response - if r.URL.Query().Get("expand") != "observations" { - for _, p := range result.Packets { - delete(p, "observations") - } - } - writeJSON(w, result) } diff --git a/cmd/server/store.go b/cmd/server/store.go index b3d1bcc7..334d8242 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -534,7 +534,7 @@ func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult { packets := make([]map[string]interface{}, 0, pageSize) if q.Order == "ASC" { for _, tx := range results[start : start+pageSize] { - packets = append(packets, txToMap(tx)) + packets = append(packets, txToMap(tx, q.ExpandObservations)) } } else { // DESC: newest items are at the tail; page 0 = last pageSize items reversed @@ -544,7 +544,7 @@ func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult { startIdx = 0 } for i := endIdx - 1; i >= startIdx; i-- { - packets = append(packets, txToMap(results[i])) + packets = append(packets, txToMap(results[i], q.ExpandObservations)) } } return &PacketResult{Packets: packets, Total: total} @@ -928,7 +928,7 @@ func (s *PacketStore) GetTransmissionByID(id int) map[string]interface{} { if tx == nil { return nil } - return txToMap(tx) + return txToMap(tx, true) } // GetPacketByHash returns a transmission by content hash. @@ -940,7 +940,7 @@ func (s *PacketStore) GetPacketByHash(hash string) map[string]interface{} { if tx == nil { return nil } - return txToMap(tx) + return txToMap(tx, true) } // GetPacketByID returns an observation (enriched with transmission fields) by observation ID. @@ -1967,7 +1967,7 @@ func (s *PacketStore) enrichObs(obs *StoreObs) map[string]interface{} { // --- Conversion helpers --- // txToMap converts a StoreTx to the map shape matching scanTransmissionRow output. -func txToMap(tx *StoreTx) map[string]interface{} { +func txToMap(tx *StoreTx, includeObservations ...bool) map[string]interface{} { m := map[string]interface{}{ "id": tx.ID, "raw_hex": strOrNil(tx.RawHex), @@ -1994,25 +1994,27 @@ func txToMap(tx *StoreTx) map[string]interface{} { } else { m["_parsedPath"] = nil } - // Include observations for expand=observations support (stripped by handler when not requested) - obs := make([]map[string]interface{}, 0, len(tx.Observations)) - for _, o := range tx.Observations { - om := map[string]interface{}{ - "id": o.ID, - "observer_id": strOrNil(o.ObserverID), - "observer_name": strOrNil(o.ObserverName), - "snr": floatPtrOrNil(o.SNR), - "rssi": floatPtrOrNil(o.RSSI), - "path_json": strOrNil(o.PathJSON), - "timestamp": strOrNil(o.Timestamp), - "direction": strOrNil(o.Direction), + // Only build observation sub-maps when caller requests them (avoids allocations that get stripped) + if len(includeObservations) > 0 && includeObservations[0] { + obs := make([]map[string]interface{}, 0, len(tx.Observations)) + for _, o := range tx.Observations { + om := map[string]interface{}{ + "id": o.ID, + "observer_id": strOrNil(o.ObserverID), + "observer_name": strOrNil(o.ObserverName), + "snr": floatPtrOrNil(o.SNR), + "rssi": floatPtrOrNil(o.RSSI), + "path_json": strOrNil(o.PathJSON), + "timestamp": strOrNil(o.Timestamp), + "direction": strOrNil(o.Direction), + } + if o.ResolvedPath != nil { + om["resolved_path"] = o.ResolvedPath + } + obs = append(obs, om) } - if o.ResolvedPath != nil { - om["resolved_path"] = o.ResolvedPath - } - obs = append(obs, om) + m["observations"] = obs } - m["observations"] = obs return m }