mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-11 09:05:51 +00:00
fix(packets): surface the transport region scope — detail pane row and a sortable Scope column (#1894)
## 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) <noreply@anthropic.com>
This commit is contained in:
@@ -266,6 +266,7 @@
|
||||
"require": "readonly",
|
||||
"routeLayer": "readonly",
|
||||
"routeTypeName": "readonly",
|
||||
"scopeCellHtml": "readonly",
|
||||
"setupPullToReconnect": "readonly",
|
||||
"syncBadgeColors": "readonly",
|
||||
"timeAgo": "readonly",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
+28
-4
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+18
-7
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) ? ' <span class="badge badge-transport" title="' + routeTypeName(rt) + '">T</span>' : ''; }
|
||||
|
||||
/**
|
||||
* 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 '<span style="color:var(--text-muted)">unknown</span>';
|
||||
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
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
+30
-2
@@ -1626,7 +1626,7 @@
|
||||
<thead><tr>
|
||||
<th scope="col" class="col-expand" data-priority="1"></th><th scope="col" class="col-region" data-sort-key="region" data-priority="3">Region</th><th scope="col" class="col-time" data-sort-key="time" data-type="date" data-priority="1">Time</th><th scope="col" class="col-hash" data-sort-key="hash" data-priority="3">Hash</th><th scope="col" class="col-size" data-sort-key="size" data-type="numeric" data-priority="4">Size</th>
|
||||
<th scope="col" class="col-hashsize" data-sort-key="hb" data-type="numeric" data-priority="5">HB</th>
|
||||
<th scope="col" class="col-type" data-sort-key="type" data-priority="1">Type</th><th scope="col" class="col-observer" data-sort-key="observer" data-priority="3">Observer</th><th scope="col" class="col-path" data-sort-key="path" data-priority="5">Path</th><th scope="col" class="col-rpt" data-sort-key="rpt" data-type="numeric" data-priority="3">Rpt</th><th scope="col" class="col-details" data-priority="1">Details</th>
|
||||
<th scope="col" class="col-type" data-sort-key="type" data-priority="1">Type</th><th scope="col" class="col-scope" data-sort-key="scope" data-priority="4">Scope</th><th scope="col" class="col-observer" data-sort-key="observer" data-priority="3">Observer</th><th scope="col" class="col-path" data-sort-key="path" data-priority="5">Path</th><th scope="col" class="col-rpt" data-sort-key="rpt" data-type="numeric" data-priority="3">Rpt</th><th scope="col" class="col-details" data-priority="1">Details</th>
|
||||
</tr></thead>
|
||||
<tbody id="pktBody"></tbody>
|
||||
</table></div>
|
||||
@@ -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 =>
|
||||
`<label><input type="checkbox" data-col="${c.key}" ${visibleCols.includes(c.key) ? 'checked' : ''}> ${c.label}</label>`
|
||||
@@ -2264,6 +2281,7 @@
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${groupSize || ''}">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="col-hashsize mono"${_grpHashSizeTitle}>${groupHashBytes}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(groupTypeName || '')}">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
|
||||
<td class="col-scope" data-filter-field="scope" data-filter-value="${escapeHtml(p.scope_name || '')}">${scopeCellHtml(p.scope_name)}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(headerObserverId) || '')}">${isSingle ? escapeHtml(truncate(obsNameOnly(headerObserverId), 16)) + obsIataBadge(p) : escapeHtml(truncate(obsNameOnly(headerObserverId), 10)) + groupedObserverIataBadgesHtml(p)}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
|
||||
@@ -2298,6 +2316,7 @@
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
<td class="col-hashsize mono"${_cHashSizeTitle}>${childHashBytes}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
|
||||
<td class="col-scope" data-filter-field="scope" data-filter-value="${escapeHtml(c.scope_name || '')}">${scopeCellHtml(c.scope_name)}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(c.observer_id) || '')}">${escapeHtml(truncate(obsNameOnly(c.observer_id), 16))}${obsIataBadge(c)}</td>
|
||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
@@ -2334,6 +2353,7 @@
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
<td class="col-hashsize mono"${_flatHashSizeTitle}>${hashBytes}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
|
||||
<td class="col-scope" data-filter-field="scope" data-filter-value="${escapeHtml(p.scope_name || '')}">${scopeCellHtml(p.scope_name)}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(p.observer_id) || '')}">${escapeHtml(truncate(obsNameOnly(p.observer_id), 16))}${obsIataBadge(p)}</td>
|
||||
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
@@ -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) {
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
@@ -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('<img src=x onerror=alert(1)>');
|
||||
assert.ok(!html.includes('<img'), 'must not emit a raw tag, got: ' + html);
|
||||
assert.ok(html.includes('<img'), 'should escape the tag, got: ' + html);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== app.js: truncate ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
|
||||
@@ -193,6 +193,37 @@ test('unclosed quote → error', () => {
|
||||
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' };
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user