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 @@
Region Time Hash Size
HB
- Type Observer Path Rpt Details
+ Type Scope Observer Path Rpt Details