diff --git a/cmd/server/coverage_test.go b/cmd/server/coverage_test.go index 9a8cd639..d707f1da 100644 --- a/cmd/server/coverage_test.go +++ b/cmd/server/coverage_test.go @@ -3811,3 +3811,105 @@ func BenchmarkIndexByNode(b *testing.B) { } }) } + +// --- Multi-observer comma-separated filter tests --- + +func TestTransmissionsForObserverMultiCSV(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + store := NewPacketStore(db, nil) + store.Load() + + t.Run("comma-separated returns union via index", func(t *testing.T) { + result := store.transmissionsForObserver("obs1,obs2", nil) + if len(result) == 0 { + t.Fatal("expected results for obs1,obs2") + } + // obs1 has transmissions 1,2,3; obs2 has transmission 1 + // Union should include all unique transmissions + obs1Only := store.transmissionsForObserver("obs1", nil) + obs2Only := store.transmissionsForObserver("obs2", nil) + if len(result) < len(obs1Only) || len(result) < len(obs2Only) { + t.Errorf("union (%d) should be >= each individual set (obs1=%d, obs2=%d)", + len(result), len(obs1Only), len(obs2Only)) + } + }) + + t.Run("comma-separated with spaces via index", func(t *testing.T) { + result := store.transmissionsForObserver("obs1, obs2", nil) + if len(result) == 0 { + t.Fatal("expected results for 'obs1, obs2' (with space)") + } + noSpace := store.transmissionsForObserver("obs1,obs2", nil) + if len(result) != len(noSpace) { + t.Errorf("with-space (%d) should equal no-space (%d)", len(result), len(noSpace)) + } + }) + + t.Run("comma-separated returns union via filter path", func(t *testing.T) { + allTx := store.packets + result := store.transmissionsForObserver("obs1,obs2", allTx) + if len(result) == 0 { + t.Fatal("expected results for obs1,obs2 via filter path") + } + }) + + t.Run("comma-separated with spaces via filter path", func(t *testing.T) { + allTx := store.packets + withSpace := store.transmissionsForObserver("obs1, obs2", allTx) + noSpace := store.transmissionsForObserver("obs1,obs2", allTx) + if len(withSpace) != len(noSpace) { + t.Errorf("filter path: with-space (%d) should equal no-space (%d)", len(withSpace), len(noSpace)) + } + }) +} + +func TestBuildTransmissionWhereMultiObserver(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + t.Run("comma-separated produces IN clause", func(t *testing.T) { + q := PacketQuery{Observer: "obs1,obs2"} + where, args := db.buildTransmissionWhere(q) + if len(where) != 1 { + t.Fatalf("expected 1 WHERE clause, got %d", len(where)) + } + clause := where[0] + if !strings.Contains(clause, "IN (?,?)") { + t.Errorf("expected IN (?,?) in clause, got: %s", clause) + } + if len(args) != 2 { + t.Fatalf("expected 2 args, got %d", len(args)) + } + if args[0] != "obs1" || args[1] != "obs2" { + t.Errorf("expected [obs1, obs2], got %v", args) + } + }) + + t.Run("comma-separated with spaces trims IDs", func(t *testing.T) { + q := PacketQuery{Observer: "obs1, obs2"} + _, args := db.buildTransmissionWhere(q) + if len(args) != 2 { + t.Fatalf("expected 2 args, got %d", len(args)) + } + if args[0] != "obs1" || args[1] != "obs2" { + t.Errorf("expected trimmed [obs1, obs2], got %v", args) + } + }) + + t.Run("single observer still works", func(t *testing.T) { + q := PacketQuery{Observer: "obs1"} + where, args := db.buildTransmissionWhere(q) + if len(where) != 1 { + t.Fatalf("expected 1 WHERE clause, got %d", len(where)) + } + if !strings.Contains(where[0], "IN (?)") { + t.Errorf("expected IN (?) for single observer, got: %s", where[0]) + } + if len(args) != 1 || args[0] != "obs1" { + t.Errorf("expected [obs1], got %v", args) + } + }) +} diff --git a/cmd/server/db.go b/cmd/server/db.go index 10b64d8a..90b1a918 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -608,12 +608,17 @@ func (db *DB) buildTransmissionWhere(q PacketQuery) ([]string, []interface{}) { args = append(args, "%"+pk+"%") } if q.Observer != "" { + ids := strings.Split(q.Observer, ",") + placeholders := strings.Repeat("?,", len(ids)) + placeholders = placeholders[:len(placeholders)-1] if db.isV3 { - where = append(where, "EXISTS (SELECT 1 FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.id = ?)") + where = append(where, "EXISTS (SELECT 1 FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.id IN ("+placeholders+"))") } else { - where = append(where, "EXISTS (SELECT 1 FROM observations oi WHERE oi.transmission_id = t.id AND oi.observer_id = ?)") + where = append(where, "EXISTS (SELECT 1 FROM observations oi WHERE oi.transmission_id = t.id AND oi.observer_id IN ("+placeholders+"))") + } + for _, id := range ids { + args = append(args, strings.TrimSpace(id)) } - args = append(args, q.Observer) } if q.Region != "" { if db.isV3 { diff --git a/cmd/server/store.go b/cmd/server/store.go index 2192c6b7..346d2e2a 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -1572,32 +1572,36 @@ func (s *PacketStore) filterPackets(q PacketQuery) []*StoreTx { } // transmissionsForObserver returns unique transmissions for an observer. -func (s *PacketStore) transmissionsForObserver(observerID string, from []*StoreTx) []*StoreTx { +func (s *PacketStore) transmissionsForObserver(observerIDs string, from []*StoreTx) []*StoreTx { + ids := strings.Split(observerIDs, ",") + idSet := make(map[string]bool, len(ids)) + for i, id := range ids { + ids[i] = strings.TrimSpace(id) + idSet[ids[i]] = true + } if from != nil { return filterTxSlice(from, func(tx *StoreTx) bool { for _, obs := range tx.Observations { - if obs.ObserverID == observerID { + if idSet[obs.ObserverID] { return true } } return false }) } - // Use byObserver index - observations := s.byObserver[observerID] - if len(observations) == 0 { - return nil - } - seen := make(map[int]bool, len(observations)) + // Use byObserver index: union transmissions for all IDs + seen := make(map[int]bool) var result []*StoreTx - for _, obs := range observations { - if seen[obs.TransmissionID] { - continue - } - seen[obs.TransmissionID] = true - tx := s.byTxID[obs.TransmissionID] - if tx != nil { - result = append(result, tx) + for _, id := range ids { + for _, obs := range s.byObserver[id] { + if seen[obs.TransmissionID] { + continue + } + seen[obs.TransmissionID] = true + tx := s.byTxID[obs.TransmissionID] + if tx != nil { + result = append(result, tx) + } } } return result diff --git a/public/packets.js b/public/packets.js index 5fccb69b..b8fab5bf 100644 --- a/public/packets.js +++ b/public/packets.js @@ -488,6 +488,7 @@ if (regionParam) params.set('region', regionParam); if (filters.hash) params.set('hash', filters.hash); if (filters.node) params.set('node', filters.node); + if (filters.observer) params.set('observer', filters.observer); params.set('groupByHash', 'true'); // always fetch grouped const data = await api('/packets?' + params.toString()); @@ -1289,7 +1290,7 @@ const types = filters.type.split(',').map(Number); displayPackets = displayPackets.filter(p => types.includes(p.payload_type)); } - if (filters.observer) { + if (filters.observer && !groupByHash) { const obsIds = new Set(filters.observer.split(',')); displayPackets = displayPackets.filter(p => obsIds.has(p.observer_id)); }