From 376c3e9f4a4bec6046fdc0f90efde39c19b6b371 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 2 Sep 2026 18:34:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(packets):=20surface=20the=20transport=20reg?= =?UTF-8?q?ion=20scope=20=E2=80=94=20detail=20pane=20row=20and=20a=20sorta?= =?UTF-8?q?ble=20Scope=20column=20(#1894)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `transmissions.scope_name` (#899) reached the database but never reached the UI. Two problems, one dead feature and one missing surface. ## 1. The detail pane's Scope row was dead `public/packets.js:3279` has rendered a **Scope** row since #899, gated on `pkt.scope_name != null`. It never fires in practice. `/api/packets` and `/api/packets/{id}` are served from the in-memory `PacketStore`. The store reads `scope_name` out of SQLite fine (`store.go:888`, `chunked_load.go:551` → `StoreTx.ScopeName`), but `txToMap()` did not put it in the JSON. Only packets old enough to have been evicted from the store — and thus served by the SQLite fallback in `db.go`, which does emit it — could ever show a scope. Verified against a live instance before the fix: ``` GET /api/packets/552e9687f1525537 → packet keys: ['_parsedPath','decoded_json','direction','first_seen','hash','id', 'observation_count','observations','observer_iata','observer_id', 'observer_name','path_json','payload_type','raw_hex','route_type','rssi','snr','timestamp'] ``` No `scope_name`. ### The NULL / "" distinction `StoreTx.ScopeName` was typed `string`, which collapses the two states the frontend distinguishes: | DB value | Meaning | UI | |---|---|---| | `NULL` | not transport-scoped | row hidden | | `""` | transport-scoped, region matched no configured key | muted "unknown scope" | | `"#be"` | matched region | the region name | `route_type` is **not** a usable proxy for that distinction: the ingestor writes NULL for a transport route whose `transport_code_1` is `0000` (`cmd/ingestor/db.go:1576` — `IsTransportScoped = route_type IN (0,3) AND Code1 ≠ "0000"`). So the field is now `*string`, with `nullStrPtr` preserving what `nullStrVal` collapsed. The two internal consumers (`TransportedScopes` #1751, `relayEntry.scope`) only care about non-empty named scopes and are unchanged in behaviour. ## 2. New: a Scope column on the packets table The scope was only reachable one packet at a time by opening the detail pane. It now has its own sortable column between Type and Observer, visible by default. The default view is **Group by Hash**, served by mappers that did not carry `scope_name` at all — so the column would have been empty in exactly the view most people look at. Both grouped paths now select and emit it: `groupedTxsToPage` in the store, and the dedicated grouped query in the DB fallback (v3 and legacy shapes). Rendering lives in `scopeCellHtml` (`public/app.js`, next to `transportBadge`) and is used on all three row-render sites — group header, expanded children, flat rows — so the column and the detail pane cannot drift apart. **Sorting** pins the empties last in both directions, as the nodes table already does for `default_scope`. Only ~8% of packets carry a scope, so an ascending sort would otherwise bury every scoped row under a wall of dashes. **Filtering**: `packet-filter.js` gains a `scope` field, so the cell is click-to-filter like Type and Observer, and `scope == "#be"` works in the filter bar. **Column prefs**: a `packets-known-cols` companion key. The `packets-visible-cols` array alone cannot distinguish "this column did not exist when you saved" from "you unchecked it", so any new column arrives silently hidden for every returning visitor. Keys absent from `known-cols` get the default treatment; keys the visitor actually hid stay hidden — there is a test for that second half specifically. ## Tests Each watched fail first. **Go** (`cmd/server/packet_scope_name_test.go`) - `txToMap` unit tests for all three states, including a JSON round-trip so a typed nil `*string` cannot pass as `null` - end-to-end through `/api/packets/{hash}` - `groupedTxsToPage` unit + end-to-end through `/api/packets?groupByHash=true`, across **both** the store-backed and DB-fallback paths - `transported_scopes_1751_test.go`: the "no scope" guard now covers both non-values (nil and a pointer to `""`) **Frontend** - `test-frontend-helpers.js`: `scopeCellHtml` three states + escaping - `test-packet-filter.js`: `scope` matching, case-insensitivity, and `FIELDS` registration - `test-packets-scope-column.js` (new Playwright e2e): header position, default visibility, one cell per row, em dash on non-transport rows, empties-last sorting, the Columns toggle, and the prefs backfill ## Verification Deployed and checked against a live instance: ``` /api/packets?groupByHash=true&limit=500 → scope_name present on 500/500, 59 with a matched region, 1 unknown-scope test-packets-scope-column.js → 7 passed, 0 failed cd cmd/server && go test ./... → ok ``` Two pre-existing failures, unrelated and equally red on an unmodified checkout: `test-e2e-playwright.js` "Customizer open does not overwrite server home config" and `test-observer-iata-1188-e2e.js` (timeout on `[data-loaded="true"]`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) --- .eslintrc.json | 1 + cmd/server/chunked_load.go | 2 +- cmd/server/db.go | 32 ++- cmd/server/packet_scope_name_test.go | 230 +++++++++++++++++++++ cmd/server/repeater_enrich_bulk.go | 4 +- cmd/server/repeater_liveness.go | 6 +- cmd/server/store.go | 25 ++- cmd/server/transported_scopes_1751_test.go | 19 +- public/app.js | 15 ++ public/packet-filter.js | 4 + public/packets.js | 32 ++- public/style.css | 5 +- test-frontend-helpers.js | 26 +++ test-packet-filter.js | 31 +++ test-packets-scope-column.js | 153 ++++++++++++++ 15 files changed, 559 insertions(+), 26 deletions(-) create mode 100644 cmd/server/packet_scope_name_test.go create mode 100644 test-packets-scope-column.js diff --git a/.eslintrc.json b/.eslintrc.json index a0c09c18..67087ec0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -266,6 +266,7 @@ "require": "readonly", "routeLayer": "readonly", "routeTypeName": "readonly", + "scopeCellHtml": "readonly", "setupPullToReconnect": "readonly", "syncBadgeColors": "readonly", "timeAgo": "readonly", diff --git a/cmd/server/chunked_load.go b/cmd/server/chunked_load.go index 457eaadb..258061fd 100644 --- a/cmd/server/chunked_load.go +++ b/cmd/server/chunked_load.go @@ -548,7 +548,7 @@ func (s *PacketStore) scanAndMergeChunk(rows *sql.Rows, relayPM *prefixMap, cold RouteType: nullIntPtr(routeType), PayloadType: nullIntPtr(payloadType), DecodedJSON: nullStrVal(decodedJSON), - ScopeName: nullStrVal(scopeName), + ScopeName: nullStrPtr(scopeName), obsKeys: make(map[string]bool), observerSet: make(map[string]bool), } diff --git a/cmd/server/db.go b/cmd/server/db.go index eafd3955..909f74a8 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -713,6 +713,13 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) { // codes across all observers of the transmission, with empty/NULL IATAs // excluded. Frontend needs this on the DEFAULT COLLAPSED VIEW (where // p._children is empty), so we compute it server-side. + // + // scope_name lives on the transmission row, so appending it as the last + // selected column is safe for both query shapes. + scopeNameCol := "" + if db.hasScopeName { + scopeNameCol = ", t.scope_name" + } var querySQL string if db.isV3 { querySQL = fmt.Sprintf(`SELECT t.hash, t.first_seen, t.raw_hex, t.decoded_json, t.payload_type, t.route_type, @@ -721,7 +728,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) { COALESCE((SELECT MAX(strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ', oi.timestamp, 'unixepoch')) FROM observations oi WHERE oi.transmission_id = t.id), t.first_seen) AS latest, obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata, o.snr, o.rssi, o.path_json, - COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas + COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas`+scopeNameCol+` FROM transmissions t LEFT JOIN observations o ON o.id = ( SELECT id FROM observations WHERE transmission_id = t.id @@ -736,7 +743,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) { COALESCE((SELECT MAX(oi.timestamp) FROM observations oi WHERE oi.transmission_id = t.id), t.first_seen) AS latest, o.observer_id, o.observer_name, COALESCE(obs2.iata, '') AS observer_iata, o.snr, o.rssi, o.path_json, - COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.id = oi.observer_id WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas + COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.id = oi.observer_id WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas`+scopeNameCol+` FROM transmissions t LEFT JOIN observations o ON o.id = ( SELECT id FROM observations WHERE transmission_id = t.id @@ -762,10 +769,15 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) { var payloadType, routeType sql.NullInt64 var count, observerCount int var snr, rssi sql.NullFloat64 + var scopeName sql.NullString - if err := rows.Scan(&hash, &firstSeen, &rawHex, &decodedJSON, &payloadType, &routeType, + scanArgs := []interface{}{&hash, &firstSeen, &rawHex, &decodedJSON, &payloadType, &routeType, &count, &observerCount, &latest, - &observerID, &observerName, &observerIATA, &snr, &rssi, &pathJSON, &distinctIatasCSV); err != nil { + &observerID, &observerName, &observerIATA, &snr, &rssi, &pathJSON, &distinctIatasCSV} + if db.hasScopeName { + scanArgs = append(scanArgs, &scopeName) + } + if err := rows.Scan(scanArgs...); err != nil { continue } @@ -787,6 +799,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) { "decoded_json": nullStr(decodedJSON), "snr": nullFloat(snr), "rssi": nullFloat(rssi), + "scope_name": nullStr(scopeName), }) } @@ -2491,6 +2504,17 @@ func nullStrVal(ns sql.NullString) string { return "" } +// nullStrPtr preserves the NULL/"" distinction that nullStrVal collapses. +// transmissions.scope_name needs it: NULL means "not transport-scoped" while +// "" means "transport-scoped, region unmatched" (#899). +func nullStrPtr(ns sql.NullString) *string { + if !ns.Valid { + return nil + } + s := ns.String + return &s +} + func nilIfEmpty(s string) interface{} { if s == "" { return nil diff --git a/cmd/server/packet_scope_name_test.go b/cmd/server/packet_scope_name_test.go new file mode 100644 index 00000000..cbf920b7 --- /dev/null +++ b/cmd/server/packet_scope_name_test.go @@ -0,0 +1,230 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" +) + +// The packet detail panel (public/packets.js) renders a "Scope" row gated on +// `pkt.scope_name != null`, distinguishing three states that transmissions.scope_name +// encodes: SQL NULL (not transport-scoped, row hidden), "" (transport-scoped but the +// region did not match a configured key → "unknown scope") and "#name" (matched +// region). txToMap is the shape /api/packets and /api/packets/{id} serve from the +// in-memory store, so it must carry all three states through. + +func TestTxToMapScopeNameMatchedRegion(t *testing.T) { + scope := "#belgium" + m := txToMap(&StoreTx{ID: 1, Hash: "aa", ScopeName: &scope}) + if m["scope_name"] != "#belgium" { + t.Errorf("scope_name = %#v, want %q", m["scope_name"], "#belgium") + } +} + +func TestTxToMapScopeNameUnknownScope(t *testing.T) { + scope := "" + m := txToMap(&StoreTx{ID: 1, Hash: "aa", ScopeName: &scope}) + v, ok := m["scope_name"] + if !ok { + t.Fatal("scope_name key missing for a transport-scoped packet with an unmatched region") + } + if v != "" { + t.Errorf("scope_name = %#v, want %q (frontend renders this as 'unknown scope')", v, "") + } +} + +func TestTxToMapScopeNameNotTransportScoped(t *testing.T) { + m := txToMap(&StoreTx{ID: 1, Hash: "aa", ScopeName: nil}) + if m["scope_name"] != nil { + t.Errorf("scope_name = %#v, want nil for a non-transport-scoped packet", m["scope_name"]) + } + // A typed nil *string in the map would marshal as null but compare non-nil in + // Go; assert the JSON the browser actually receives. + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded["scope_name"] != nil { + t.Errorf("JSON scope_name = %#v, want null", decoded["scope_name"]) + } +} + +// TestPacketDetailExposesScopeName is the end-to-end guard: the packet-detail +// endpoint is served from the in-memory store, so scope_name must survive the +// SQL scan (nullStrPtr) and the map conversion (txToMap) with all three states +// intact. Before this test, txToMap dropped the field entirely and the Scope row +// only ever rendered for packets old enough to fall through to the DB. +func TestPacketDetailExposesScopeName(t *testing.T) { + db := setupTestDB(t) + if _, err := db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { + t.Fatalf("add scope_name column: %v", err) + } + db.hasScopeName = true + + now := time.Now().UTC().Format(time.RFC3339) + // route_type 1 = FLOOD (never transport-scoped → NULL); 0 = TRANSPORT_FLOOD. + if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type) + VALUES ('AABB', 'aaaaaaaaaaaaaaa1', ?, 1, 4)`, now); err != nil { + t.Fatalf("insert unscoped: %v", err) + } + if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, scope_name) + VALUES ('AABB', 'aaaaaaaaaaaaaaa2', ?, 0, 4, '')`, now); err != nil { + t.Fatalf("insert unknown-scope: %v", err) + } + if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, scope_name) + VALUES ('AABB', 'aaaaaaaaaaaaaaa3', ?, 0, 4, '#belgium')`, now); err != nil { + t.Fatalf("insert matched-scope: %v", err) + } + + srv := NewServer(db, &Config{Port: 3000}, NewHub()) + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load: %v", err) + } + if !store.WaitIndexesReady(5 * time.Second) { + t.Fatal("background indexes never became ready") + } + srv.store = store + router := mux.NewRouter() + srv.RegisterRoutes(router) + + cases := []struct { + name string + hash string + want interface{} + }{ + {"not transport-scoped", "aaaaaaaaaaaaaaa1", nil}, + {"transport-scoped, region unmatched", "aaaaaaaaaaaaaaa2", ""}, + {"transport-scoped, region matched", "aaaaaaaaaaaaaaa3", "#belgium"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if store.GetPacketByHash(tc.hash) == nil { + t.Fatalf("precondition: %s not in store (would hit the DB fallback)", tc.hash) + } + req := httptest.NewRequest("GET", "/api/packets/"+tc.hash, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + pkt, ok := body["packet"].(map[string]interface{}) + if !ok { + t.Fatal("expected packet object") + } + if got := pkt["scope_name"]; got != tc.want { + t.Errorf("scope_name = %#v, want %#v", got, tc.want) + } + }) + } +} + +// --- grouped view --- +// +// The Packets tab defaults to "Group by Hash", which is served by a separate +// mapper (groupedTxsToPage in the store, a dedicated query in the DB fallback). +// The Scope column reads scope_name off those rows, so both paths must carry it. + +func TestGroupedTxsToPageCarriesScopeName(t *testing.T) { + matched := "#belgium" + unmatched := "" + txs := []*StoreTx{ + {ID: 1, Hash: "aa", ScopeName: &matched}, + {ID: 2, Hash: "bb", ScopeName: &unmatched}, + {ID: 3, Hash: "cc", ScopeName: nil}, + } + res := groupedTxsToPage(txs, len(txs), 0, len(txs)) + want := []interface{}{"#belgium", "", nil} + for i, w := range want { + if got := res.Packets[i]["scope_name"]; got != w { + t.Errorf("packet %d: scope_name = %#v, want %#v", i, got, w) + } + } +} + +func TestGroupedPacketsEndpointExposesScopeName(t *testing.T) { + db := setupTestDB(t) + if _, err := db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { + t.Fatalf("add scope_name column: %v", err) + } + db.hasScopeName = true + if _, err := db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + + now := time.Now().UTC().Format(time.RFC3339) + if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type) + VALUES ('AABB', 'bbbbbbbbbbbbbbb1', ?, 1, 4)`, now); err != nil { + t.Fatalf("insert unscoped: %v", err) + } + if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, scope_name) + VALUES ('AABB', 'bbbbbbbbbbbbbbb2', ?, 0, 4, '')`, now); err != nil { + t.Fatalf("insert unknown-scope: %v", err) + } + if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, scope_name) + VALUES ('AABB', 'bbbbbbbbbbbbbbb3', ?, 0, 4, '#belgium')`, now); err != nil { + t.Fatalf("insert matched-scope: %v", err) + } + + want := map[string]interface{}{ + "bbbbbbbbbbbbbbb1": nil, + "bbbbbbbbbbbbbbb2": "", + "bbbbbbbbbbbbbbb3": "#belgium", + } + + // Both the store-backed path and the DB-only fallback must agree. + for _, withStore := range []bool{true, false} { + name := "store" + if !withStore { + name = "db" + } + t.Run(name, func(t *testing.T) { + srv := NewServer(db, &Config{Port: 3000}, NewHub()) + if withStore { + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load: %v", err) + } + if !store.WaitIndexesReady(5 * time.Second) { + t.Fatal("background indexes never became ready") + } + srv.store = store + } + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/packets?groupByHash=true&limit=50", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + var body struct { + Packets []map[string]interface{} `json:"packets"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(body.Packets) != 3 { + t.Fatalf("expected 3 grouped packets, got %d", len(body.Packets)) + } + for _, p := range body.Packets { + h, _ := p["hash"].(string) + if got := p["scope_name"]; got != want[h] { + t.Errorf("%s: scope_name = %#v, want %#v", h, got, want[h]) + } + } + }) + } +} diff --git a/cmd/server/repeater_enrich_bulk.go b/cmd/server/repeater_enrich_bulk.go index 1182f81d..b3ed7f84 100644 --- a/cmd/server/repeater_enrich_bulk.go +++ b/cmd/server/repeater_enrich_bulk.go @@ -159,11 +159,11 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin // #1902: it IS gated on full-pubkey attribution — a 1-byte // hop cannot prove which of the nodes sharing that byte // carried the packet. - if tx.ScopeName != "" && !viaPrefix { + if tx.ScopeName != nil && *tx.ScopeName != "" && !viaPrefix { if scopeSet == nil { scopeSet = map[string]struct{}{} } - scopeSet[tx.ScopeName] = struct{}{} + scopeSet[*tx.ScopeName] = struct{}{} } if !p.ok { continue diff --git a/cmd/server/repeater_liveness.go b/cmd/server/repeater_liveness.go index d5eec01d..f51e7720 100644 --- a/cmd/server/repeater_liveness.go +++ b/cmd/server/repeater_liveness.go @@ -168,7 +168,11 @@ func (s *PacketStore) collectRelayEntriesLocked(key string) []relayEntry { if tx.RouteType != nil { rt = *tx.RouteType } - entries = append(entries, relayEntry{ts: tx.FirstSeen, pt: pt, rt: rt, scope: tx.ScopeName, viaPrefix: viaPrefix}) + scope := "" + if tx.ScopeName != nil { + scope = *tx.ScopeName + } + entries = append(entries, relayEntry{ts: tx.FirstSeen, pt: pt, rt: rt, scope: scope, viaPrefix: viaPrefix}) } } collect(txList, false) diff --git a/cmd/server/store.go b/cmd/server/store.go index a2da88ac..273cf90d 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -38,9 +38,11 @@ type StoreTx struct { PayloadType *int DecodedJSON string // ScopeName is the transmission's region scope name (transmissions.scope_name, - // #899). Empty on schemas without the column (db.hasScopeName=false). Used to - // surface the set of region scopes a repeater has transported (#1751). - ScopeName string + // #899). nil means the row is not transport-scoped (SQL NULL), or the schema + // has no such column (db.hasScopeName=false); a pointer to "" means + // transport-scoped with an unmatched region. Used to surface the set of region + // scopes a repeater has transported (#1751) and the packet-detail Scope row. + ScopeName *string Observations []*StoreObs ObservationCount int // Display fields from longest-path observation @@ -879,7 +881,7 @@ func (s *PacketStore) Load() error { RouteType: nullIntPtr(routeType), PayloadType: nullIntPtr(payloadType), DecodedJSON: nullStrVal(decodedJSON), - ScopeName: nullStrVal(scopeName), + ScopeName: nullStrPtr(scopeName), obsKeys: make(map[string]bool), observerSet: make(map[string]bool), } @@ -1207,7 +1209,7 @@ func (s *PacketStore) loadChunk(from, to time.Time) error { RouteType: nullIntPtr(routeType), PayloadType: nullIntPtr(payloadType), DecodedJSON: nullStrVal(decodedJSON), - ScopeName: nullStrVal(scopeName), + ScopeName: nullStrPtr(scopeName), obsKeys: make(map[string]bool), observerSet: make(map[string]bool), } @@ -1978,6 +1980,7 @@ func groupedTxsToPage(txs []*StoreTx, total, offset, limit int) *PacketResult { "decoded_json": strOrNil(tx.DecodedJSON), "snr": floatPtrOrNil(tx.SNR), "rssi": floatPtrOrNil(tx.RSSI), + "scope_name": strPtrOrNil(tx.ScopeName), } // resolved_path omitted for grouped view (cold path, not worth SQL round-trip) packets[i] = m @@ -2611,7 +2614,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac obsID *int observerID, observerName, observerIATA, direction, pathJSON, obsTS string obsRawHex string - scopeName string + scopeName *string snr, rssi *float64 score *int } @@ -2668,7 +2671,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac pathJSON: nullStrVal(pathJSON), obsTS: nullStrVal(obsTimestamp), obsRawHex: nullStrVal(obsRawHex), - scopeName: nullStrVal(scopeName), + scopeName: nullStrPtr(scopeName), snr: nullFloatPtr(snrVal), rssi: nullFloatPtr(rssiVal), score: nullIntPtr(scoreVal), @@ -3823,6 +3826,7 @@ func txToMap(tx *StoreTx, includeObservations ...bool) map[string]interface{} { "rssi": floatPtrOrNil(tx.RSSI), "path_json": strOrNil(tx.PathJSON), "direction": strOrNil(tx.Direction), + "scope_name": strPtrOrNil(tx.ScopeName), } // Include parsed path array to match Node.js output shape if hops := txGetParsedPath(tx); len(hops) > 0 { @@ -3895,6 +3899,13 @@ func normalizeTimestamp(s string) string { return s } +func strPtrOrNil(p *string) interface{} { + if p == nil { + return nil + } + return *p +} + func intPtrOrNil(p *int) interface{} { if p == nil { return nil diff --git a/cmd/server/transported_scopes_1751_test.go b/cmd/server/transported_scopes_1751_test.go index 4d65ff84..0013cbf2 100644 --- a/cmd/server/transported_scopes_1751_test.go +++ b/cmd/server/transported_scopes_1751_test.go @@ -30,7 +30,7 @@ func scopeTx(id int, payloadType int, scope string) *StoreTx { ID: id, Hash: "scope-tx-" + scope + "-" + strconv.Itoa(id), PayloadType: &pt, - ScopeName: scope, + ScopeName: &scope, FirstSeen: time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339Nano), } } @@ -81,22 +81,25 @@ func TestTransportedScopes_PerNodeMatchesBulk(t *testing.T) { } // TestTransportedScopes_EmptyWhenNoScope guards the "field absent" contract: -// a repeater whose path-hop packets carry no scope_name (older schema / -// hasScopeName=false → ScopeName always "") must yield a nil/empty slice so -// routes.go omits the JSON field entirely. +// a repeater whose path-hop packets carry no usable scope_name must yield a +// nil/empty slice so routes.go omits the JSON field entirely. Both non-values +// count: ScopeName nil (not transport-scoped, or older schema with +// hasScopeName=false) and a pointer to "" (transport-scoped, region unmatched). func TestTransportedScopes_EmptyWhenNoScope(t *testing.T) { - noScope := scopeTx(1, 2, "") // non-advert but ScopeName=="" + unmatchedScope := scopeTx(1, 2, "") // non-advert, transport-scoped, region unmatched + noScope := scopeTx(2, 2, "") // non-advert, not transport-scoped at all + noScope.ScopeName = nil store := &PacketStore{ - byPathHop: map[string][]*StoreTx{scope1751Key: {noScope}}, + byPathHop: map[string][]*StoreTx{scope1751Key: {unmatchedScope, noScope}}, mu: sync.RWMutex{}, } if got := store.computeRepeaterRelayInfoMap(24)[scope1751Key].TransportedScopes; len(got) != 0 { - t.Fatalf("bulk: expected no scopes when ScopeName empty, got %v", got) + t.Fatalf("bulk: expected no scopes when ScopeName empty/nil, got %v", got) } if got := store.GetRepeaterRelayInfo(scope1751Key, 24).TransportedScopes; len(got) != 0 { - t.Fatalf("per-node: expected no scopes when ScopeName empty, got %v", got) + t.Fatalf("per-node: expected no scopes when ScopeName empty/nil, got %v", got) } } diff --git a/public/app.js b/public/app.js index 4455bbbb..28281fa4 100644 --- a/public/app.js +++ b/public/app.js @@ -14,6 +14,21 @@ function isTransportRoute(rt) { return rt === 0 || rt === 3; } function getPathLenOffset(routeType) { return isTransportRoute(routeType) ? 5 : 1; } function transportBadge(rt) { return isTransportRoute(rt) ? ' T' : ''; } +/** + * Render a packet's transport region scope (transmissions.scope_name) for the + * Scope column and the detail pane. Three states, matching what the DB stores: + * null/undefined — not transport-scoped; FLOOD and DIRECT carry no + * transport_code_1 at all, so there is nothing to show. + * "" — transport-scoped, but the code matched no configured + * hashRegions entry. + * "#be" — the matched region name. + */ +function scopeCellHtml(scopeName) { + if (scopeName == null) return '—'; + if (scopeName === '') return 'unknown'; + return escapeHtml(scopeName); +} + /** * Compute breakdown byte ranges from raw_hex on the client. * Mirrors cmd/server/decoder.go BuildBreakdown(). Used so per-observation raw_hex diff --git a/public/packet-filter.js b/public/packet-filter.js index 4185a287..610d9fc5 100644 --- a/public/packet-filter.js +++ b/public/packet-filter.js @@ -281,6 +281,9 @@ if (field === 'hops') { try { return JSON.parse(packet.path_json || '[]').length; } catch(e) { return 0; } } + // Transport region scope. null (not transport-scoped) and "" (transport-scoped, + // region unmatched) both collapse to "" — neither has a scope to match on. + if (field === 'scope') return packet.scope_name || ''; if (field === 'observer') return packet.observer_name || ''; if (field === 'observer_id') return packet.observer_id || ''; if (field === 'observer_iata' || field === 'iata') return packet.observer_iata || ''; @@ -483,6 +486,7 @@ { name: 'snr', desc: 'Signal-to-noise ratio (dB)' }, { name: 'rssi', desc: 'Received signal strength (dBm)' }, { name: 'hops', desc: 'Number of hops in the path' }, + { name: 'scope', desc: 'Transport region scope (e.g. #be). Empty for non-transport routes and unmatched regions.' }, { name: 'observer', desc: 'Observer station name' }, { name: 'observer_id', desc: 'Observer pubkey/id' }, { name: 'observer_iata', desc: 'Observer IATA region code (e.g. SJC, SFO)' }, diff --git a/public/packets.js b/public/packets.js index 36de3eed..d70ce03a 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1626,7 +1626,7 @@ RegionTimeHashSize HB - TypeObserverPathRptDetails + TypeScopeObserverPathRptDetails @@ -2010,6 +2010,7 @@ { key: 'hash', label: 'Hash' }, { key: 'size', label: 'Size' }, { key: 'type', label: 'Type' }, + { key: 'scope', label: 'Scope' }, { key: 'observer', label: 'Observer' }, { key: 'path', label: 'Path' }, { key: 'rpt', label: 'Rpt' }, @@ -2019,12 +2020,27 @@ // #1249: observer column must stay visible at narrow widths so the IATA // badge (#1188) renders on mobile. Without observer in scope the user // can't see who heard the packet at all. - const defaultHidden = isNarrow ? ['region', 'hash', 'path', 'rpt', 'size'] : ['region']; + const defaultHidden = isNarrow ? ['region', 'hash', 'path', 'rpt', 'size', 'scope'] : ['region']; let visibleCols; + let knownCols; try { visibleCols = JSON.parse(localStorage.getItem('packets-visible-cols')); + knownCols = JSON.parse(localStorage.getItem('packets-known-cols')); } catch {} if (!visibleCols) visibleCols = COL_DEFS.map(c => c.key).filter(k => !defaultHidden.includes(k)); + else { + // A column added after the visitor last saved their preferences is absent + // from the stored array for the same reason a column they unchecked is: + // the array alone can't tell the two apart, so a new column would arrive + // silently hidden. `packets-known-cols` records which keys existed at save + // time; anything newer than that gets the default treatment instead. + if (!Array.isArray(knownCols)) knownCols = ['region', 'time', 'hash', 'size', 'type', 'observer', 'path', 'rpt', 'details']; + COL_DEFS.forEach(c => { + if (!knownCols.includes(c.key) && !visibleCols.includes(c.key) && !defaultHidden.includes(c.key)) { + visibleCols.push(c.key); + } + }); + } const colMenu = document.getElementById('colToggleMenu'); const pktTable = document.getElementById('pktTable'); function applyColVisibility() { @@ -2032,6 +2048,7 @@ pktTable.classList.toggle('hide-col-' + c.key, !visibleCols.includes(c.key)); }); localStorage.setItem('packets-visible-cols', JSON.stringify(visibleCols)); + localStorage.setItem('packets-known-cols', JSON.stringify(COL_DEFS.map(c => c.key))); } colMenu.innerHTML = COL_DEFS.map(c => `` @@ -2264,6 +2281,7 @@ ${groupSize ? groupSize + 'B' : '—'} ${groupHashBytes} ${p.payload_type != null ? `${groupTypeName}${transportBadge(p.route_type)}` : '—'} + ${scopeCellHtml(p.scope_name)} ${isSingle ? escapeHtml(truncate(obsNameOnly(headerObserverId), 16)) + obsIataBadge(p) : escapeHtml(truncate(obsNameOnly(headerObserverId), 10)) + groupedObserverIataBadgesHtml(p)} ${groupPathStr} ${p.observation_count > 1 ? ' ' + p.observation_count + '' : (isSingle ? '' : p.count)} @@ -2298,6 +2316,7 @@ ${size}B ${childHashBytes} ${typeName}${transportBadge(c.route_type)} + ${scopeCellHtml(c.scope_name)} ${escapeHtml(truncate(obsNameOnly(c.observer_id), 16))}${obsIataBadge(c)} ${childPathStr} @@ -2334,6 +2353,7 @@ ${size}B ${hashBytes} ${typeName}${transportBadge(p.route_type)} + ${scopeCellHtml(p.scope_name)} ${escapeHtml(truncate(obsNameOnly(p.observer_id), 16))}${obsIataBadge(p)} ${pathStr} @@ -2700,6 +2720,7 @@ case 'rpt': accessor = function(p) { try { var pj = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : p.path_json; return Array.isArray(pj) ? pj.length : 0; } catch(e) { return 0; } }; break; + case 'scope': accessor = function(p) { return p.scope_name || ''; }; break; case 'region': accessor = function(p) { return (regionMap && regionMap[p.observer_id]) || ''; }; break; case 'path': accessor = function(p) { try { var pj = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : p.path_json; return Array.isArray(pj) ? pj.join(',') : ''; } catch(e) { return ''; } @@ -2712,6 +2733,13 @@ var isDate = (col === 'time'); packets.sort(function(a, b) { + // Most packets carry no scope (FLOOD and DIRECT cannot), so an ascending + // sort would bury every scoped row under a wall of dashes. Pin the empties + // last in BOTH directions, as the nodes table does for default_scope. + if (col === 'scope') { + var aHasScope = a.scope_name ? 1 : 0, bHasScope = b.scope_name ? 1 : 0; + if (aHasScope !== bHasScope) return bHasScope - aHasScope; + } var va = accessor(a), vb = accessor(b); var result; if (isDate) { diff --git a/public/style.css b/public/style.css index 66761e0a..15b45c8e 100644 --- a/public/style.css +++ b/public/style.css @@ -2127,6 +2127,8 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } .obs-table td:first-child { white-space: nowrap; } .obs-table td:nth-child(6) { max-width: none; overflow: visible; } .col-observer { min-width: 70px; max-width: none; } +/* Region names run to "#nl-nb-mie"; clip rather than push the wider columns. */ +.col-scope { min-width: 56px; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 12px; } .spark-bar { position: relative; min-width: 60px; max-width: 100px; flex: 1; height: 18px; background: var(--border); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; } @media (max-width: 640px) { .spark-bar { max-width: 60px; } } .spark-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-hover, #60a5fa)); border-radius: 4px; transition: width 0.3s; } @@ -2993,7 +2995,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } /* Hide low-value columns on mobile */ @media (max-width: 640px) { - .col-region, .col-rpt, .col-size, .col-hashsize, .col-pubkey { display: none; } + .col-region, .col-rpt, .col-size, .col-hashsize, .col-pubkey, .col-scope { display: none; } } /* Clickable hop links */ @@ -3185,6 +3187,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); } .hide-col-hash .col-hash, .hide-col-size .col-size, .hide-col-type .col-type, +.hide-col-scope .col-scope, .hide-col-observer .col-observer, .hide-col-path .col-path, .hide-col-rpt .col-rpt, diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 53c70185..1b5bf2e7 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -235,6 +235,32 @@ console.log('\n=== app.js: routeTypeName / payloadTypeName ==='); test('getPathLenOffset: direct route (2) → 1', () => assert.strictEqual(ctx.getPathLenOffset(2), 1)); } +console.log('\n=== app.js: scopeCellHtml ==='); +{ + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + + // transmissions.scope_name has three states; the Scope column in the packets + // table must render each one distinguishably. + test('scope_name null (not transport-scoped) → em dash', () => + assert.strictEqual(ctx.scopeCellHtml(null), '—')); + test('scope_name undefined (older API without the field) → em dash', () => + assert.strictEqual(ctx.scopeCellHtml(undefined), '—')); + test('scope_name "" (transport-scoped, region unmatched) → muted unknown', () => { + const html = ctx.scopeCellHtml(''); + assert.ok(html.includes('unknown'), 'should say unknown, got: ' + html); + assert.ok(html.includes('text-muted'), 'should be muted, got: ' + html); + }); + test('scope_name "#be" → the region name', () => + assert.strictEqual(ctx.scopeCellHtml('#be'), '#be')); + test('scope_name is escaped', () => { + const html = ctx.scopeCellHtml(''); + assert.ok(!html.includes(' { assert(c.error !== null, 'should have error'); }); +// --- Transport region scope filter field --- +// scope_name is null for non-transport routes, "" when transport-scoped but the +// region did not match a configured key, and "#be" on a match. +const bePkt = { ...pkt, scope_name: '#be' }; +const unknownScopePkt = { ...pkt, scope_name: '' }; +const noScopePkt = { ...pkt, scope_name: null }; + +test('scope == "#be" matches a packet scoped to #be', () => { + assert(PF.compile('scope == "#be"').filter(bePkt)); +}); +test('scope == "#be" is case-insensitive', () => { + assert(PF.compile('scope == "#BE"').filter(bePkt)); +}); +test('scope == "#nl" does not match a #be packet', () => { + assert(!PF.compile('scope == "#nl"').filter(bePkt)); +}); +test('scope == "" matches transport-scoped with an unmatched region', () => { + assert(PF.compile('scope == ""').filter(unknownScopePkt)); + assert(!PF.compile('scope == ""').filter(bePkt)); +}); +test('scope == "" also matches a non-transport packet (both render as no scope)', () => { + assert(PF.compile('scope == ""').filter(noScopePkt)); +}); +test('scope contains "be" matches #be', () => { + assert(PF.compile('scope contains "be"').filter(bePkt)); +}); +test('scope listed in FIELDS suggestions', () => { + const names = PF.FIELDS.map(f => f.name); + assert(names.indexOf('scope') !== -1, 'scope in FIELDS'); +}); + // --- Observer IATA filter field (#1188) --- const iataPkt = { ...pkt, observer_iata: 'SJC' }; const sfoPkt = { ...pkt, observer_iata: 'SFO' }; diff --git a/test-packets-scope-column.js b/test-packets-scope-column.js new file mode 100644 index 00000000..0d966888 --- /dev/null +++ b/test-packets-scope-column.js @@ -0,0 +1,153 @@ +/** + * Playwright E2E — Scope column on the Packets tab. + * + * transmissions.scope_name has three states and the column must render each one + * distinguishably: em dash (not transport-scoped), muted "unknown" + * (transport-scoped, region unmatched) and the region name on a match. + * + * Usage: node test-packets-scope-column.js + * BASE_URL=https://staging.on8ar.eu node test-packets-scope-column.js + */ +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:3000'; +const results = []; + +async function test(name, fn) { + try { + await fn(); + results.push({ name, pass: true }); + console.log(` ✅ ${name}`); + } catch (err) { + results.push({ name, pass: false, error: err.message }); + console.log(` ❌ ${name}: ${err.message}`); + } +} + +function assert(condition, msg) { + if (!condition) throw new Error(msg || 'Assertion failed'); +} + +async function gotoPackets(page) { + await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { + localStorage.removeItem('packets-visible-cols'); + localStorage.removeItem('packets-known-cols'); + }); + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll])', { timeout: 30000 }); +} + +(async () => { + console.log(`\nPackets Scope column — ${BASE}\n`); + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } }); + + await gotoPackets(page); + + await test('Scope header sits between Type and Observer', async () => { + const headers = await page.$$eval('#pktTable thead th', ths => + ths.map(th => th.textContent.trim())); + const iType = headers.indexOf('Type'); + const iScope = headers.indexOf('Scope'); + const iObserver = headers.indexOf('Observer'); + assert(iScope !== -1, 'Scope header missing, got: ' + headers.join('|')); + assert(iType < iScope && iScope < iObserver, + `expected Type < Scope < Observer, got ${iType} < ${iScope} < ${iObserver}`); + }); + + await test('Scope column is visible by default', async () => { + const hidden = await page.$eval('#pktTable', t => t.classList.contains('hide-col-scope')); + assert(!hidden, 'table carries hide-col-scope on a fresh visit'); + const cellCount = await page.$$eval('#pktTable tbody td.col-scope', tds => tds.length); + assert(cellCount > 0, 'no td.col-scope rendered'); + }); + + await test('every row renders exactly one scope cell', async () => { + const { rows, cells } = await page.evaluate(() => { + const trs = Array.from(document.querySelectorAll('#pktTable tbody tr')) + .filter(tr => !tr.id.startsWith('vscroll') && tr.querySelector('td.col-type')); + return { + rows: trs.length, + cells: trs.filter(tr => tr.querySelectorAll('td.col-scope').length === 1).length, + }; + }); + assert(rows > 0, 'no packet rows found'); + assert(rows === cells, `${rows} rows but ${cells} have exactly one scope cell`); + }); + + await test('non-transport rows render an em dash', async () => { + const found = await page.evaluate(() => { + for (const tr of document.querySelectorAll('#pktTable tbody tr')) { + const type = tr.querySelector('td.col-type'); + const scope = tr.querySelector('td.col-scope'); + if (!type || !scope) continue; + // No T badge → FLOOD or DIRECT → no transport scope possible. + if (!type.querySelector('.badge-transport')) return scope.textContent.trim(); + } + return null; + }); + assert(found !== null, 'no non-transport row on screen to check'); + assert(found === '—', `expected em dash, got "${found}"`); + }); + + await test('sorting by Scope pins the empties last', async () => { + await page.click('#pktTable thead th.col-scope'); + await page.waitForTimeout(600); + const values = await page.$$eval('#pktTable tbody td.col-scope', tds => + tds.map(td => td.textContent.trim())); + const lastScoped = values.reduce((acc, v, i) => (v !== '—' ? i : acc), -1); + const firstEmpty = values.indexOf('—'); + if (lastScoped === -1 || firstEmpty === -1) { + console.log(' (only one scope state on screen, ordering not exercised)'); + return; + } + assert(firstEmpty > lastScoped, + `em dashes must follow every scoped row; first dash at ${firstEmpty}, last scoped at ${lastScoped}`); + }); + + await test('Columns menu can hide and restore the Scope column', async () => { + // A checkbox click bubbles to the document handler that closes the menu, so + // each toggle needs its own open. + const toggleScope = async () => { + await page.click('#colToggleBtn'); + await page.waitForTimeout(200); + const box = await page.$('#colToggleMenu input[data-col="scope"]'); + assert(box, 'no Scope checkbox in the Columns menu'); + const wasChecked = await box.isChecked(); + await box.click(); + await page.waitForTimeout(300); + return wasChecked; + }; + + assert(await toggleScope(), 'Scope checkbox should start checked'); + assert(await page.$eval('#pktTable', t => t.classList.contains('hide-col-scope')), + 'unchecking should add hide-col-scope'); + assert(!(await toggleScope()), 'Scope checkbox should now be unchecked'); + assert(!(await page.$eval('#pktTable', t => t.classList.contains('hide-col-scope'))), + 're-checking should remove hide-col-scope'); + }); + + await test('a column added after the visitor saved prefs arrives visible', async () => { + // Simulate a returning visitor whose stored prefs predate the Scope column. + await page.evaluate(() => { + localStorage.setItem('packets-visible-cols', + JSON.stringify(['time', 'hash', 'size', 'type', 'observer', 'path', 'rpt', 'details'])); + localStorage.removeItem('packets-known-cols'); + }); + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll])', { timeout: 30000 }); + assert(!(await page.$eval('#pktTable', t => t.classList.contains('hide-col-scope'))), + 'Scope should be shown for prefs saved before the column existed'); + // Region was explicitly absent from those prefs AND predates the column, so + // the backfill must not resurrect it — only genuinely new keys get defaulted. + assert(await page.$eval('#pktTable', t => t.classList.contains('hide-col-region')), + 'backfill must not re-enable a column the visitor had hidden'); + }); + + await browser.close(); + + const failed = results.filter(r => !r.pass); + console.log(`\n=== ${results.length - failed.length} passed, ${failed.length} failed ===`); + process.exit(failed.length ? 1 : 0); +})();