Compare commits

...

7 Commits

Author SHA1 Message Date
OpenClaw Bot ee414b4114 chore(#1188): swap fixture-dependent E2E for string-contract unit test
The Playwright E2E variant of this test depended on observer joins
working in test-fixtures/e2e-fixture.db, but the fixture stores text
pubkeys in observations.observer_idx (an INTEGER column populated as
rowid in production). The join LEFT JOIN observers obs ON
obs.rowid = o.observer_idx returns no rows on the fixture, so
observer_iata is always null and the badge never renders during E2E.

Replaced with a Node.js string-contract test that asserts:
- public/packets.js defines obsIataBadge() reading packet.observer_iata
- the badge is rendered in all 3 table surfaces + 2 detail surfaces
- public/style.css declares .badge-iata using CSS variables (no hex)

Wired into deploy.yml alongside the existing js unit tests.
2026-05-11 05:48:18 +00:00
OpenClaw Bot 7a3f45d9c1 feat(#1188): green — render observer IATA badge on packets surfaces
Three display surfaces show the per-observation IATA inline with the
observer name as a compact .badge-iata pill:
- packets table group/header row, expanded observation child row, and
  flat row in public/packets.js
- packet detail pane Observer row + per-observation list in detail
- public/style.css: new .badge-iata using CSS variables (--nav-bg/-text);
  no inline hex

Prefers packet.observer_iata from /api/packets (added in the prior
backend commit) over a client-side observers.find() lookup (#383).
Missing IATA renders nothing inline (the observer name still shows);
the existing — em-dash convention stays on the Region column.
2026-05-11 05:37:47 +00:00
OpenClaw Bot 0e09371ec3 test(#1188): red — E2E asserts observer IATA badge in packets table
Adds test-observer-iata-1188-e2e.js asserting:
- the packets table observer column renders a .badge-iata element
- the badge text is a 3-letter IATA code (e.g. SJC)
- filter expression iata == "<CODE>" narrows the table to matching rows

Wires the new test into deploy.yml's e2e-test job. Currently RED: no
display code renders .badge-iata in .col-observer yet.
2026-05-11 05:35:55 +00:00
OpenClaw Bot 7856914a56 feat(#1188): green — surface observer_iata in /api/packets responses
Backend changes to expose observer IATA per packet/observation:
- cmd/server/db.go: SELECT obs.iata in transmissionBaseSQL, grouped query,
  and getObservationsForTransmissions; v2 schema uses LEFT JOIN observers
  via observer_id
- cmd/server/store.go: extend StoreTx/StoreObs with ObserverIATA; load via
  initial loadSQL + incremental ingest + observation-only ingest; surface
  in txToMap, enrichObs, and groupedTxsToPage
- cmd/server/types.go: add ObserverIATA to TransmissionResp, ObservationResp,
  GroupedPacketResp
- cmd/server/routes.go: copy observer_iata through mapSliceToTransmissions
  and mapSliceToObservations

Test fixture schemas updated: observers table now declares iata column
(matches production schema).
2026-05-11 05:34:24 +00:00
OpenClaw Bot 4ed272761b test(#1188): red — /api/packets must return observer_iata per row
Adds three tests asserting the API surfaces observer_iata on:
- ungrouped /api/packets rows
- grouped /api/packets?groupByHash=true rows
- per-observation entries in /api/packets/{id} detail response

Currently fails: the SQL joins select obs.id, obs.name but not obs.iata.
Fixing the join in cmd/server/db.go is the green commit.
2026-05-11 05:22:15 +00:00
OpenClaw Bot 2c182ebd23 feat(#1188): green — observer_iata/iata filter grammar evaluator
- resolveField returns packet.observer_iata for both 'observer_iata' and
  'iata' (alias) field names
- in (...) operator evaluator: case-insensitive membership in value list
- Add observer_iata + iata to FIELDS metadata (autocomplete dropdown)
- Add 'in' to OPERATORS list with example
2026-05-11 05:20:12 +00:00
OpenClaw Bot 271d72f19d test(#1188): red — observer_iata/iata filter grammar tests
Add tests for observer_iata and iata (alias) filter fields:
- equality (==, !=)
- in (a, b, c)
- contains
- missing-iata handling
- combined with type
- presence in suggest field list

Also adds parser support for: 'in' operator + comma + LPAREN/RPAREN
value lists, so tests reach the assertion stage (not parse errors).
Field stub returns empty string — tests fail on the assertion that
'observer_iata == "SJC"' should match a packet with that IATA.
2026-05-11 05:18:02 +00:00
14 changed files with 394 additions and 50 deletions
+1
View File
@@ -98,6 +98,7 @@ jobs:
node test-channel-modal-ux.js
node test-channel-issue-1087.js
node test-channel-issue-1101.js
node test-observer-iata-1188.js
node test-pull-to-reconnect-1091.js
node test-channel-fluid-layout.js
+3 -3
View File
@@ -162,7 +162,7 @@ func createTestDBWithAgedPackets(t *testing.T, numRecent, numOld int) string {
}
execOrFail(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT)`)
execOrFail(`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT, direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT)`)
execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
execOrFail(`CREATE TABLE nodes (pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, frequency REAL)`)
execOrFail(`CREATE TABLE schema_version (version INTEGER)`)
execOrFail(`INSERT INTO schema_version (version) VALUES (1)`)
@@ -317,7 +317,7 @@ func createTestDBAt(tb testing.TB, dbPath string, numTx int) {
direction TEXT, snr REAL, rssi REAL, score INTEGER,
path_json TEXT, timestamp TEXT, raw_hex TEXT
)`)
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
execOrFail(`CREATE TABLE IF NOT EXISTS nodes (
pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, first_seen TEXT, frequency REAL
@@ -368,7 +368,7 @@ func createTestDBWithObs(tb testing.TB, dbPath string, numTx int) {
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
)`)
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
execOrFail(`CREATE TABLE IF NOT EXISTS nodes (
pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, first_seen TEXT, frequency REAL
+19 -13
View File
@@ -89,7 +89,7 @@ func (db *DB) transmissionBaseSQL() (selectCols, observerJoin string) {
if db.isV3 {
selectCols = `t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.decoded_json,
COALESCE((SELECT COUNT(*) FROM observations WHERE transmission_id = t.id), 0) AS observation_count,
obs.id AS observer_id, obs.name AS observer_name,
obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata,
o.snr, o.rssi, o.path_json, o.direction`
observerJoin = `LEFT JOIN observations o ON o.id = (
SELECT id FROM observations WHERE transmission_id = t.id
@@ -99,12 +99,13 @@ func (db *DB) transmissionBaseSQL() (selectCols, observerJoin string) {
} else {
selectCols = `t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.decoded_json,
COALESCE((SELECT COUNT(*) FROM observations WHERE transmission_id = t.id), 0) AS observation_count,
o.observer_id, o.observer_name,
o.observer_id, o.observer_name, COALESCE(obs2.iata, '') AS observer_iata,
o.snr, o.rssi, o.path_json, o.direction`
observerJoin = `LEFT JOIN observations o ON o.id = (
SELECT id FROM observations WHERE transmission_id = t.id
ORDER BY length(COALESCE(path_json,'')) DESC LIMIT 1
)`
)
LEFT JOIN observers obs2 ON obs2.id = o.observer_id`
}
return
}
@@ -113,12 +114,12 @@ func (db *DB) transmissionBaseSQL() (selectCols, observerJoin string) {
// Returns a map matching the Node.js packet-store transmission shape.
func (db *DB) scanTransmissionRow(rows *sql.Rows) map[string]interface{} {
var id, observationCount int
var rawHex, hash, firstSeen, decodedJSON, observerID, observerName, pathJSON, direction sql.NullString
var rawHex, hash, firstSeen, decodedJSON, observerID, observerName, observerIATA, pathJSON, direction sql.NullString
var routeType, payloadType sql.NullInt64
var snr, rssi sql.NullFloat64
if err := rows.Scan(&id, &rawHex, &hash, &firstSeen, &routeType, &payloadType, &decodedJSON,
&observationCount, &observerID, &observerName, &snr, &rssi, &pathJSON, &direction); err != nil {
&observationCount, &observerID, &observerName, &observerIATA, &snr, &rssi, &pathJSON, &direction); err != nil {
return nil
}
@@ -134,6 +135,7 @@ func (db *DB) scanTransmissionRow(rows *sql.Rows) map[string]interface{} {
"observation_count": observationCount,
"observer_id": nullStr(observerID),
"observer_name": nullStr(observerName),
"observer_iata": nullStr(observerIATA),
"snr": nullFloat(snr),
"rssi": nullFloat(rssi),
"path_json": nullStr(pathJSON),
@@ -476,7 +478,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
COALESCE((SELECT COUNT(*) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS count,
COALESCE((SELECT COUNT(DISTINCT oi.observer_idx) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS observer_count,
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,
obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata,
o.snr, o.rssi, o.path_json
FROM transmissions t
LEFT JOIN observations o ON o.id = (
@@ -490,13 +492,14 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
COALESCE((SELECT COUNT(*) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS count,
COALESCE((SELECT COUNT(DISTINCT oi.observer_id) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS observer_count,
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,
o.observer_id, o.observer_name, COALESCE(obs2.iata, '') AS observer_iata,
o.snr, o.rssi, o.path_json
FROM transmissions t
LEFT JOIN observations o ON o.id = (
SELECT id FROM observations WHERE transmission_id = t.id
ORDER BY length(COALESCE(path_json,'')) DESC LIMIT 1
)
LEFT JOIN observers obs2 ON obs2.id = o.observer_id
%s ORDER BY latest DESC LIMIT ? OFFSET ?`, w)
}
@@ -512,14 +515,14 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
packets := make([]map[string]interface{}, 0)
for rows.Next() {
var hash, firstSeen, rawHex, decodedJSON, latest, observerID, observerName, pathJSON sql.NullString
var hash, firstSeen, rawHex, decodedJSON, latest, observerID, observerName, observerIATA, pathJSON sql.NullString
var payloadType, routeType sql.NullInt64
var count, observerCount int
var snr, rssi sql.NullFloat64
if err := rows.Scan(&hash, &firstSeen, &rawHex, &decodedJSON, &payloadType, &routeType,
&count, &observerCount, &latest,
&observerID, &observerName, &snr, &rssi, &pathJSON); err != nil {
&observerID, &observerName, &observerIATA, &snr, &rssi, &pathJSON); err != nil {
continue
}
@@ -532,6 +535,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
"latest": nullStr(latest),
"observer_id": nullStr(observerID),
"observer_name": nullStr(observerName),
"observer_iata": nullStr(observerIATA),
"path_json": nullStr(pathJSON),
"payload_type": nullInt(payloadType),
"route_type": nullInt(routeType),
@@ -967,16 +971,17 @@ func (db *DB) getObservationsForTransmissions(txIDs []int) map[int][]map[string]
var querySQL string
if db.isV3 {
querySQL = fmt.Sprintf(`SELECT o.transmission_id, o.id, obs.id AS observer_id, obs.name AS observer_name,
querySQL = fmt.Sprintf(`SELECT o.transmission_id, o.id, obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata,
o.direction, o.snr, o.rssi, o.path_json, strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ', o.timestamp, 'unixepoch') AS obs_timestamp
FROM observations o
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
WHERE o.transmission_id IN (%s)
ORDER BY o.timestamp DESC`, strings.Join(placeholders, ","))
} else {
querySQL = fmt.Sprintf(`SELECT o.transmission_id, o.id, o.observer_id, o.observer_name,
querySQL = fmt.Sprintf(`SELECT o.transmission_id, o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, '') AS observer_iata,
o.direction, o.snr, o.rssi, o.path_json, o.timestamp AS obs_timestamp
FROM observations o
LEFT JOIN observers obs ON obs.id = o.observer_id
WHERE o.transmission_id IN (%s)
ORDER BY o.timestamp DESC`, strings.Join(placeholders, ","))
}
@@ -989,10 +994,10 @@ func (db *DB) getObservationsForTransmissions(txIDs []int) map[int][]map[string]
for rows.Next() {
var txID, obsID int
var observerID, observerName, direction, pathJSON, obsTimestamp sql.NullString
var observerID, observerName, observerIATA, direction, pathJSON, obsTimestamp sql.NullString
var snr, rssi sql.NullFloat64
if err := rows.Scan(&txID, &obsID, &observerID, &observerName, &direction,
if err := rows.Scan(&txID, &obsID, &observerID, &observerName, &observerIATA, &direction,
&snr, &rssi, &pathJSON, &obsTimestamp); err != nil {
continue
}
@@ -1007,6 +1012,7 @@ func (db *DB) getObservationsForTransmissions(txIDs []int) map[int][]map[string]
"transmission_id": txID,
"observer_id": nullStr(observerID),
"observer_name": nullStr(observerName),
"observer_iata": nullStr(observerIATA),
"snr": nullFloat(snr),
"rssi": nullFloat(rssi),
"path_json": nullStr(pathJSON),
+3 -3
View File
@@ -476,10 +476,10 @@ func TestBuildNodeInfoMap_ObserverEnrichment(t *testing.T) {
// Create tables
for _, stmt := range []string{
"CREATE TABLE nodes (public_key TEXT, name TEXT, role TEXT, lat REAL, lon REAL)",
"CREATE TABLE observers (id TEXT, name TEXT)",
"CREATE TABLE observers (id TEXT, name TEXT, iata TEXT)",
"INSERT INTO nodes VALUES ('AAAA1111', 'Repeater-1', 'repeater', 0, 0)",
"INSERT INTO observers VALUES ('BBBB2222', 'Observer-Alpha')",
"INSERT INTO observers VALUES ('AAAA1111', 'Obs-also-repeater')",
"INSERT INTO observers VALUES ('BBBB2222', 'Observer-Alpha', '')",
"INSERT INTO observers VALUES ('AAAA1111', 'Obs-also-repeater', '')",
} {
if _, err := conn.Exec(stmt); err != nil {
t.Fatalf("exec %q: %v", stmt, err)
+121
View File
@@ -0,0 +1,121 @@
// Test (#1188): /api/packets response must include observer_iata per packet
// so the frontend can render the IATA inline without per-row observer lookups.
package main
import (
"encoding/json"
"net/http/httptest"
"testing"
)
// TestPacketsEndpointIncludesObserverIATA asserts the ungrouped packets endpoint
// surfaces the joined observer's IATA on each packet row.
func TestPacketsEndpointIncludesObserverIATA(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/packets?limit=10", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
packets, ok := body["packets"].([]interface{})
if !ok || len(packets) == 0 {
t.Fatal("expected non-empty packets array")
}
// Seeded observers: obs1 → SJC, obs2 → SFO. At least one packet row
// must carry a non-empty observer_iata string.
gotIATA := false
for _, p := range packets {
m, _ := p.(map[string]interface{})
if m == nil {
continue
}
if _, present := m["observer_iata"]; !present {
t.Fatalf("packet missing observer_iata field; got keys: %v", keysOfMap(m))
}
if s, _ := m["observer_iata"].(string); s != "" {
gotIATA = true
}
}
if !gotIATA {
t.Fatalf("expected at least one packet with non-empty observer_iata (seed has SJC/SFO)")
}
}
// TestPacketsGroupedIncludesObserverIATA asserts the grouped (groupByHash)
// view also surfaces observer_iata for the header row.
func TestPacketsGroupedIncludesObserverIATA(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/packets?groupByHash=true&limit=10", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
packets, _ := body["packets"].([]interface{})
if len(packets) == 0 {
t.Fatal("expected non-empty grouped packets")
}
gotIATA := false
for _, p := range packets {
m, _ := p.(map[string]interface{})
if _, present := m["observer_iata"]; !present {
t.Fatalf("grouped packet missing observer_iata field; got keys: %v", keysOfMap(m))
}
if s, _ := m["observer_iata"].(string); s != "" {
gotIATA = true
}
}
if !gotIATA {
t.Fatalf("expected at least one grouped packet with non-empty observer_iata")
}
}
// TestPacketDetailObservationsIncludeIATA asserts /api/packets/{id} returns
// per-observation observer_iata so the detail pane can render it.
func TestPacketDetailObservationsIncludeIATA(t *testing.T) {
_, router := setupTestServer(t)
// transmission_id 1 has two observations (obs1 SJC, obs2 SFO) from seedTestData
req := httptest.NewRequest("GET", "/api/packets/1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
obs, _ := body["observations"].([]interface{})
if len(obs) == 0 {
t.Fatalf("expected observations in detail response; body: %s", w.Body.String())
}
gotIATA := false
for _, o := range obs {
m, _ := o.(map[string]interface{})
if _, present := m["observer_iata"]; !present {
t.Fatalf("observation missing observer_iata field; got keys: %v", keysOfMap(m))
}
if s, _ := m["observer_iata"].(string); s != "" {
gotIATA = true
}
}
if !gotIATA {
t.Fatalf("expected at least one observation with non-empty observer_iata")
}
}
func keysOfMap(m map[string]interface{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+2
View File
@@ -2484,6 +2484,7 @@ func mapSliceToTransmissions(maps []map[string]interface{}) []TransmissionResp {
}
tx.ObserverID = m["observer_id"]
tx.ObserverName = m["observer_name"]
tx.ObserverIATA = m["observer_iata"]
tx.SNR = m["snr"]
tx.RSSI = m["rssi"]
tx.PathJSON = m["path_json"]
@@ -2506,6 +2507,7 @@ func mapSliceToObservations(maps []map[string]interface{}) []ObservationResp {
obs.Hash = m["hash"]
obs.ObserverID = m["observer_id"]
obs.ObserverName = m["observer_name"]
obs.ObserverIATA = m["observer_iata"]
obs.SNR = m["snr"]
obs.RSSI = m["rssi"]
obs.PathJSON = m["path_json"]
+37 -21
View File
@@ -37,6 +37,7 @@ type StoreTx struct {
// Display fields from longest-path observation
ObserverID string
ObserverName string
ObserverIATA string
SNR *float64
RSSI *float64
PathJSON string
@@ -59,6 +60,7 @@ type StoreObs struct {
TransmissionID int
ObserverID string
ObserverName string
ObserverIATA string
Direction string
SNR *float64
RSSI *float64
@@ -491,7 +493,7 @@ func (s *PacketStore) Load() error {
if s.db.isV3 {
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, obs.id, obs.name, o.direction,
o.id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + `
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id
@@ -500,10 +502,11 @@ func (s *PacketStore) Load() error {
} else {
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, o.observer_id, o.observer_name, o.direction,
o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + `
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id` + filterClause + `
LEFT JOIN observations o ON o.transmission_id = t.id
LEFT JOIN observers obs ON obs.id = o.observer_id` + filterClause + `
ORDER BY t.first_seen ASC, o.timestamp DESC`
}
@@ -520,7 +523,7 @@ func (s *PacketStore) Load() error {
var rawHex, hash, firstSeen, decodedJSON sql.NullString
var routeType, payloadType, payloadVersion sql.NullInt64
var obsID sql.NullInt64
var observerID, observerName, direction, pathJSON, obsTimestamp sql.NullString
var observerID, observerName, observerIATA, direction, pathJSON, obsTimestamp sql.NullString
var snr, rssi sql.NullFloat64
var score sql.NullInt64
var obsRawHex sql.NullString
@@ -528,7 +531,7 @@ func (s *PacketStore) Load() error {
scanArgs := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
&payloadVersion, &decodedJSON,
&obsID, &observerID, &observerName, &direction,
&obsID, &observerID, &observerName, &observerIATA, &direction,
&snr, &rssi, &score, &pathJSON, &obsTimestamp}
if s.db.hasObsRawHex {
scanArgs = append(scanArgs, &obsRawHex)
@@ -587,6 +590,7 @@ func (s *PacketStore) Load() error {
TransmissionID: txID,
ObserverID: obsIDStr,
ObserverName: nullStrVal(observerName),
ObserverIATA: nullStrVal(observerIATA),
Direction: nullStrVal(direction),
SNR: nullFloatPtr(snr),
RSSI: nullFloatPtr(rssi),
@@ -697,6 +701,7 @@ func pickBestObservation(tx *StoreTx) {
}
tx.ObserverID = best.ObserverID
tx.ObserverName = best.ObserverName
tx.ObserverIATA = best.ObserverIATA
tx.SNR = best.SNR
tx.RSSI = best.RSSI
tx.PathJSON = best.PathJSON
@@ -962,6 +967,7 @@ func groupedTxsToPage(txs []*StoreTx, total, offset, limit int) *PacketResult {
"latest": strOrNil(tx.LatestSeen),
"observer_id": strOrNil(tx.ObserverID),
"observer_name": strOrNil(tx.ObserverName),
"observer_iata": strOrNil(tx.ObserverIATA),
"path_json": strOrNil(tx.PathJSON),
"payload_type": intPtrOrNil(tx.PayloadType),
"route_type": intPtrOrNil(tx.RouteType),
@@ -1419,7 +1425,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
if s.db.isV3 {
querySQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, obs.id, obs.name, o.direction,
o.id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRHCol + `
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id
@@ -1429,10 +1435,11 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
} else {
querySQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, o.observer_id, o.observer_name, o.direction,
o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRHCol + `
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id
LEFT JOIN observers obs ON obs.id = o.observer_id
WHERE t.id > ?
ORDER BY t.id ASC, o.timestamp DESC`
}
@@ -1446,14 +1453,14 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
// Scan into temp structures
type tempRow struct {
txID int
rawHex, hash, firstSeen, decodedJSON string
routeType, payloadType *int
obsID *int
observerID, observerName, direction, pathJSON, obsTS string
obsRawHex string
snr, rssi *float64
score *int
txID int
rawHex, hash, firstSeen, decodedJSON string
routeType, payloadType *int
obsID *int
observerID, observerName, observerIATA, direction, pathJSON, obsTS string
obsRawHex string
snr, rssi *float64
score *int
}
var tempRows []tempRow
@@ -1465,14 +1472,14 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
var rawHex, hash, firstSeen, decodedJSON sql.NullString
var routeType, payloadType, payloadVersion sql.NullInt64
var obsIDVal sql.NullInt64
var observerID, observerName, direction, pathJSON, obsTimestamp sql.NullString
var observerID, observerName, observerIATA, direction, pathJSON, obsTimestamp sql.NullString
var snrVal, rssiVal sql.NullFloat64
var scoreVal sql.NullInt64
var obsRawHex sql.NullString
scanArgs2 := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
&payloadVersion, &decodedJSON,
&obsIDVal, &observerID, &observerName, &direction,
&obsIDVal, &observerID, &observerName, &observerIATA, &direction,
&snrVal, &rssiVal, &scoreVal, &pathJSON, &obsTimestamp}
if s.db.hasObsRawHex {
scanArgs2 = append(scanArgs2, &obsRawHex)
@@ -1499,6 +1506,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
payloadType: nullIntPtr(payloadType),
observerID: nullStrVal(observerID),
observerName: nullStrVal(observerName),
observerIATA: nullStrVal(observerIATA),
direction: nullStrVal(direction),
pathJSON: nullStrVal(pathJSON),
obsTS: nullStrVal(obsTimestamp),
@@ -1598,6 +1606,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
TransmissionID: r.txID,
ObserverID: r.observerID,
ObserverName: r.observerName,
ObserverIATA: r.observerIATA,
Direction: r.direction,
SNR: r.snr,
RSSI: r.rssi,
@@ -1851,7 +1860,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
obsRHCol2 = ", o.raw_hex"
}
if s.db.isV3 {
querySQL = `SELECT o.id, o.transmission_id, obs.id, obs.name, o.direction,
querySQL = `SELECT o.id, o.transmission_id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRHCol2 + `
FROM observations o
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
@@ -1859,9 +1868,10 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
ORDER BY o.id ASC
LIMIT ?`
} else {
querySQL = `SELECT o.id, o.transmission_id, o.observer_id, o.observer_name, o.direction,
querySQL = `SELECT o.id, o.transmission_id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRHCol2 + `
FROM observations o
LEFT JOIN observers obs ON obs.id = o.observer_id
WHERE o.id > ?
ORDER BY o.id ASC
LIMIT ?`
@@ -1879,6 +1889,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
txID int
observerID string
observerName string
observerIATA string
direction string
snr, rssi *float64
score *int
@@ -1890,12 +1901,12 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
var obsRows []obsRow
for rows.Next() {
var oid, txID int
var observerID, observerName, direction, pathJSON, ts sql.NullString
var observerID, observerName, observerIATA, direction, pathJSON, ts sql.NullString
var snr, rssi sql.NullFloat64
var score sql.NullInt64
var obsRawHex sql.NullString
scanArgs3 := []interface{}{&oid, &txID, &observerID, &observerName, &direction,
scanArgs3 := []interface{}{&oid, &txID, &observerID, &observerName, &observerIATA, &direction,
&snr, &rssi, &score, &pathJSON, &ts}
if s.db.hasObsRawHex {
scanArgs3 = append(scanArgs3, &obsRawHex)
@@ -1909,6 +1920,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
txID: txID,
observerID: nullStrVal(observerID),
observerName: nullStrVal(observerName),
observerIATA: nullStrVal(observerIATA),
direction: nullStrVal(direction),
snr: nullFloatPtr(snr),
rssi: nullFloatPtr(rssi),
@@ -1965,6 +1977,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
TransmissionID: r.txID,
ObserverID: r.observerID,
ObserverName: r.observerName,
ObserverIATA: r.observerIATA,
Direction: r.direction,
SNR: r.snr,
RSSI: r.rssi,
@@ -2589,6 +2602,7 @@ func (s *PacketStore) enrichObs(obs *StoreObs) map[string]interface{} {
"timestamp": strOrNil(obs.Timestamp),
"observer_id": strOrNil(obs.ObserverID),
"observer_name": strOrNil(obs.ObserverName),
"observer_iata": strOrNil(obs.ObserverIATA),
"direction": strOrNil(obs.Direction),
"snr": floatPtrOrNil(obs.SNR),
"rssi": floatPtrOrNil(obs.RSSI),
@@ -2633,6 +2647,7 @@ func txToMap(tx *StoreTx, includeObservations ...bool) map[string]interface{} {
"observation_count": tx.ObservationCount,
"observer_id": strOrNil(tx.ObserverID),
"observer_name": strOrNil(tx.ObserverName),
"observer_iata": strOrNil(tx.ObserverIATA),
"snr": floatPtrOrNil(tx.SNR),
"rssi": floatPtrOrNil(tx.RSSI),
"path_json": strOrNil(tx.PathJSON),
@@ -2652,6 +2667,7 @@ func txToMap(tx *StoreTx, includeObservations ...bool) map[string]interface{} {
"id": o.ID,
"observer_id": strOrNil(o.ObserverID),
"observer_name": strOrNil(o.ObserverName),
"observer_iata": strOrNil(o.ObserverIATA),
"snr": floatPtrOrNil(o.SNR),
"rssi": floatPtrOrNil(o.RSSI),
"path_json": strOrNil(o.PathJSON),
+3 -3
View File
@@ -34,7 +34,7 @@ func TestTopologyDedup_RepeatersMergeByPubkey(t *testing.T) {
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
)`)
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
exec(`CREATE TABLE nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, frequency REAL
@@ -158,7 +158,7 @@ func TestTopologyDedup_AmbiguousPrefixNotMerged(t *testing.T) {
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
)`)
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
exec(`CREATE TABLE nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, frequency REAL
@@ -264,7 +264,7 @@ func TestTopologyDedup_PairsMergeByPubkey(t *testing.T) {
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
)`)
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
exec(`CREATE TABLE nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, frequency REAL
+3
View File
@@ -260,6 +260,7 @@ type TransmissionResp struct {
ObservationCount int `json:"observation_count"`
ObserverID interface{} `json:"observer_id"`
ObserverName interface{} `json:"observer_name"`
ObserverIATA interface{} `json:"observer_iata"`
SNR interface{} `json:"snr"`
RSSI interface{} `json:"rssi"`
PathJSON interface{} `json:"path_json"`
@@ -274,6 +275,7 @@ type ObservationResp struct {
Hash interface{} `json:"hash,omitempty"`
ObserverID interface{} `json:"observer_id"`
ObserverName interface{} `json:"observer_name"`
ObserverIATA interface{} `json:"observer_iata"`
SNR interface{} `json:"snr"`
RSSI interface{} `json:"rssi"`
PathJSON interface{} `json:"path_json"`
@@ -291,6 +293,7 @@ type GroupedPacketResp struct {
Latest string `json:"latest"`
ObserverID interface{} `json:"observer_id"`
ObserverName interface{} `json:"observer_name"`
ObserverIATA interface{} `json:"observer_iata"`
PathJSON interface{} `json:"path_json"`
PayloadType int `json:"payload_type"`
RouteType int `json:"route_type"`
+51 -2
View File
@@ -23,10 +23,10 @@
var TK = {
FIELD: 'FIELD', OP: 'OP', STRING: 'STRING', NUMBER: 'NUMBER', BOOL: 'BOOL',
DURATION: 'DURATION',
AND: 'AND', OR: 'OR', NOT: 'NOT', LPAREN: 'LPAREN', RPAREN: 'RPAREN'
AND: 'AND', OR: 'OR', NOT: 'NOT', LPAREN: 'LPAREN', RPAREN: 'RPAREN', COMMA: 'COMMA'
};
var OP_WORDS = { contains: true, starts_with: true, ends_with: true, after: true, before: true, between: true };
var OP_WORDS = { contains: true, starts_with: true, ends_with: true, after: true, before: true, between: true, in: true };
// Duration unit → seconds. Used for `age < 1h`-style filters.
var DURATION_UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
@@ -50,6 +50,7 @@
if (input[i] === '!') { tokens.push({ type: TK.NOT, value: '!' }); i++; continue; }
if (input[i] === '(') { tokens.push({ type: TK.LPAREN }); i++; continue; }
if (input[i] === ')') { tokens.push({ type: TK.RPAREN }); i++; continue; }
if (input[i] === ',') { tokens.push({ type: TK.COMMA, value: ',' }); i++; continue; }
// quoted string
if (input[i] === '"') {
var j = i + 1;
@@ -179,6 +180,28 @@
return { type: 'comparison', field: field, op: op, value: lo, value2: hi };
}
// `in` takes a parenthesized list of values: `field in (a, b, c)`
if (op === 'in') {
if (!peek() || peek().type !== TK.LPAREN) {
throw new Error("Expected '(' after 'in'");
}
advance(); // consume '('
var values = [];
if (!peek() || peek().type === TK.RPAREN) {
throw new Error("Empty value list for 'in'");
}
values.push(parseValue(field, op));
while (peek() && peek().type === TK.COMMA) {
advance(); // consume ','
values.push(parseValue(field, op));
}
if (!peek() || peek().type !== TK.RPAREN) {
throw new Error("Expected ')' or ',' in 'in' list");
}
advance(); // consume ')'
return { type: 'comparison', field: field, op: op, values: values };
}
var value = parseValue(field, op);
if (op === 'after' || op === 'before') validateTimeValue(field, op, value);
return { type: 'comparison', field: field, op: op, value: value };
@@ -233,6 +256,7 @@
}
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 || '';
if (field === 'observations') return packet.observation_count || 0;
if (field === 'time' || field === 'timestamp') {
// Returns ms-since-epoch or null. Falls back to first_seen when timestamp absent
@@ -304,6 +328,16 @@
if (fieldVal == null || fieldVal === undefined) return false;
// `in` operator: membership in a list of values (case-insensitive for strings)
if (op === 'in') {
var list = ast.values || [];
var lhs = String(fieldVal).toLowerCase();
for (var iv = 0; iv < list.length; iv++) {
if (String(list[iv]).toLowerCase() === lhs) return true;
}
return false;
}
// Temporal ops: after / before / between operate on epoch-ms.
if (op === 'after' || op === 'before' || op === 'between') {
var lhsMs = typeof fieldVal === 'number' ? fieldVal : Date.parse(fieldVal);
@@ -397,6 +431,8 @@
{ name: 'hops', desc: 'Number of hops in the path' },
{ 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)' },
{ name: 'iata', desc: 'Alias of observer_iata' },
{ name: 'observations', desc: 'Number of observations of this packet' },
{ name: 'path', desc: 'Hop path (joined with arrows)' },
{ name: 'payload_bytes', desc: 'Payload size in bytes (size - 2 header bytes)' },
@@ -428,6 +464,7 @@
{ op: 'after', desc: 'Datetime after (ISO or epoch)', example: 'time after "2025-01-01"' },
{ op: 'before', desc: 'Datetime before', example: 'time before "2025-12-31"' },
{ op: 'between', desc: 'Datetime between two values', example: 'time between "2025-01-01" "2025-02-01"' },
{ op: 'in', desc: 'Value in a list (case-insensitive for strings)', example: 'iata in ("SJC","SFO")' },
];
// Canonical type names (firmware payload types)
@@ -611,6 +648,18 @@
c = compile('observer == "kpabap"');
assert(c.filter({ observer_name: 'kpabap' }), 'observer');
// Observer IATA (#1188)
c = compile('observer_iata == "SJC"');
assert(c.filter({ observer_iata: 'SJC' }), 'observer_iata ==');
assert(!c.filter({ observer_iata: 'SFO' }), 'observer_iata != mismatch');
c = compile('iata == "SJC"');
assert(c.filter({ observer_iata: 'SJC' }), 'iata alias');
c = compile('iata in ("SJC","SFO")');
assert(c.filter({ observer_iata: 'SFO' }), 'iata in (...)');
assert(!c.filter({ observer_iata: 'LAX' }), 'iata in (...) mismatch');
c = compile('observer_iata contains "S"');
assert(c.filter({ observer_iata: 'SJC' }), 'observer_iata contains');
console.log('\nAll tests passed!');
module.exports = { parse: parse, evaluate: evaluate, compile: compile };
}
+25 -5
View File
@@ -443,6 +443,26 @@
if (!o) return id;
return o.iata ? `${o.name} (${o.iata})` : o.name;
}
// Compact IATA pill (#1188) — renders next to observer name. Prefers
// packet.observer_iata (now joined on the server) and falls back to the
// observer lookup map for callers that haven't been updated yet.
function obsIataBadge(packet) {
if (!packet) return '';
let iata = packet.observer_iata;
if (!iata) {
const o = packet.observer_id ? observerMap.get(packet.observer_id) : null;
iata = o && o.iata;
}
return iata ? `<span class="badge-iata">${escapeHtml(iata)}</span>` : '';
}
// Plain observer name without the trailing IATA — used when the IATA is
// rendered separately as a badge (so the cell doesn't show "Name (SJC) SJC").
function obsNameOnly(id) {
if (!id) return '—';
const o = observerMap.get(id);
if (!o) return id;
return o.name;
}
let selectedId = null;
function _isColorByHash() { return localStorage.getItem('meshcore-color-packets-by-hash') !== 'false'; }
function _currentTheme() { return document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); }
@@ -1923,7 +1943,7 @@
<td class="col-size" data-filter-field="size" data-filter-value="${groupSize || ''}">${groupSize ? groupSize + 'B' : '—'}</td>
<td class="col-hashsize mono">${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-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(headerObserverId) || '')}">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(headerObserverId) || '')}">${isSingle ? truncate(obsNameOnly(headerObserverId), 16) + obsIataBadge(p) : truncate(obsNameOnly(headerObserverId), 10) + obsIataBadge(p) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</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">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
<td class="col-details">${getDetailPreview(getParsedDecoded(p))}</td>
@@ -1949,7 +1969,7 @@
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
<td class="col-hashsize mono">${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-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(c.observer_id) || '')}">${truncate(obsName(c.observer_id), 16)}</td>
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(c.observer_id) || '')}">${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>
<td class="col-details">${getDetailPreview(getParsedDecoded(c))}</td>
@@ -1981,7 +2001,7 @@
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
<td class="col-hashsize mono">${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-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(p.observer_id) || '')}">${truncate(obsName(p.observer_id), 16)}</td>
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(p.observer_id) || '')}">${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>
<td class="col-details">${detail}</td>
@@ -2801,7 +2821,7 @@
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}${obsIndicator}</div>
${messageHtml}
<dl class="detail-meta">
<dt>Observer</dt><dd>${obsName(effectivePkt.observer_id)}</dd>
<dt>Observer</dt><dd>${obsNameOnly(effectivePkt.observer_id)}${obsIataBadge(effectivePkt)}</dd>
<dt>Location</dt><dd>${locationHtml}</dd>
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
@@ -2839,7 +2859,7 @@
const oPath = getParsedPath(o);
const isCurrent = currentObs && String(o.id) === String(currentObs.id);
return `<tr class="detail-obs-row${isCurrent ? ' observation-current' : ''}" data-obs-id="${o.id}" style="cursor:pointer;${isCurrent ? 'background:var(--accent-bg, rgba(0,122,255,0.1))' : ''}" title="Click to view this observation">
<td style="padding:4px 6px">${obsName(o.observer_id)}</td>
<td style="padding:4px 6px">${obsNameOnly(o.observer_id)}${obsIataBadge(o)}</td>
<td style="padding:4px 6px">${oPath.length}</td>
<td style="padding:4px 6px">${o.snr != null ? o.snr + ' dB' : '—'}</td>
<td style="padding:4px 6px">${o.rssi != null ? o.rssi + ' dBm' : '—'}</td>
+9
View File
@@ -877,6 +877,15 @@ body.scroll-locked { overflow: hidden; }
font-size: 10px; font-weight: 700; font-family: var(--mono);
background: var(--nav-bg); color: var(--nav-text); letter-spacing: .5px;
}
/* Observer IATA pill rendered inline next to observer name on packets (#1188).
* Visually similar to .badge-region but distinct so the row badge and the
* inline-with-observer badge can be styled independently in future themes. */
.badge-iata {
display: inline-block; padding: 1px 5px; border-radius: 4px;
font-size: 10px; font-weight: 700; font-family: var(--mono);
background: var(--nav-bg); color: var(--nav-text); letter-spacing: .5px;
margin-left: 4px; vertical-align: middle;
}
/* TODO: expose --transport-badge-bg/fg in customizer THEME_CSS_MAP (tracked in future milestone) */
.badge-transport {
display: inline-block; padding: 1px 5px; border-radius: 4px;
+73
View File
@@ -0,0 +1,73 @@
/**
* Test (#1188): public/packets.js must render observer IATA inline
* next to observer name on all three packet-viewing surfaces (table
* flat row, expanded observation child row, group/header row) AND
* in the detail pane's Observer field + per-observation list.
*
* String-contract test (no browser): grep the source file for the
* expected fragments so a future refactor can't silently drop them.
*
* Runs in Node.js no browser. Wired into deploy.yml CI alongside
* test-packet-filter.js and the other unit harnesses.
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' \u2705 ' + msg); }
else { failed++; console.error(' \u274c ' + msg); }
}
const src = fs.readFileSync(path.join(__dirname, 'public/packets.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
// ── Helper presence ─────────────────────────────────────────────────────────
assert(/function\s+obsIataBadge\s*\(/.test(src),
'obsIataBadge() helper defined in packets.js');
assert(/function\s+obsNameOnly\s*\(/.test(src),
'obsNameOnly() helper defined (renders name without inline IATA, lets badge render separately)');
// ── Helper must prefer packet.observer_iata (avoids per-row observers.find()) ──
const helperSnippet = (() => {
// Function spans roughly 8 lines; capture liberally then trim
const m = src.match(/function\s+obsIataBadge\s*\(packet\)\s*\{[\s\S]*?return\s+iata\s*\?[^;]*;\s*\}/);
return m ? m[0] : '';
})();
assert(helperSnippet.length > 0, 'obsIataBadge body extractable');
assert(/packet\.observer_iata/.test(helperSnippet),
'obsIataBadge reads packet.observer_iata directly (server-joined field, no client lookup)');
assert(/badge-iata/.test(helperSnippet),
'obsIataBadge emits the badge-iata class');
// ── All three table surfaces render the badge ───────────────────────────────
// Count occurrences of obsIataBadge( in row-building templates
const obsIataBadgeCalls = (src.match(/obsIataBadge\(/g) || []).length;
assert(obsIataBadgeCalls >= 5,
`obsIataBadge invoked at least 5x (group row + child row + flat row + detail Observer dd + detail-obs-row); got ${obsIataBadgeCalls}`);
// Surface 1: grouped header observer cell
assert(/col-observer[\s\S]{0,200}obsIataBadge\(p\)/.test(src),
'grouped row col-observer cell calls obsIataBadge(p)');
// Surface 2: expanded observation child row
assert(/col-observer[\s\S]{0,200}obsIataBadge\(c\)/.test(src),
'expanded child row col-observer cell calls obsIataBadge(c)');
// Surface 3: detail pane Observer <dd>
assert(/<dt>Observer<\/dt><dd>[\s\S]{0,200}obsIataBadge\(effectivePkt\)/.test(src),
'detail pane Observer dd calls obsIataBadge(effectivePkt)');
// Surface 4: per-observation list in detail
assert(/detail-obs-row[\s\S]*?obsIataBadge\(o\)/.test(src),
'detail-obs-row observer cell calls obsIataBadge(o)');
// ── CSS: badge-iata class defined; uses CSS variables, no new hex ───────────
assert(/\.badge-iata\s*\{/.test(css),
'.badge-iata class defined in style.css');
const badgeIataBlock = (css.match(/\.badge-iata\s*\{[\s\S]*?\}/) || [''])[0];
assert(/var\(--/.test(badgeIataBlock),
'.badge-iata uses CSS variables for colors (no inline hex)');
assert(!/#[0-9a-fA-F]{3,8}/.test(badgeIataBlock),
'.badge-iata block contains no raw hex colors');
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
process.exit(failed > 0 ? 1 : 0);
+44
View File
@@ -188,5 +188,49 @@ test('unclosed quote → error', () => {
assert(c.error !== null, 'should have error');
});
// --- Observer IATA filter field (#1188) ---
const iataPkt = { ...pkt, observer_iata: 'SJC' };
const sfoPkt = { ...pkt, observer_iata: 'SFO' };
const noIataPkt = { ...pkt, observer_iata: null };
test('observer_iata == "SJC" matches', () => {
assert(PF.compile('observer_iata == "SJC"').filter(iataPkt));
});
test('observer_iata == "SJC" case-insensitive', () => {
assert(PF.compile('observer_iata == "sjc"').filter(iataPkt));
});
test('observer_iata == "SFO" does not match SJC packet', () => {
assert(!PF.compile('observer_iata == "SFO"').filter(iataPkt));
});
test('iata alias works like observer_iata', () => {
assert(PF.compile('iata == "SJC"').filter(iataPkt));
assert(!PF.compile('iata == "LAX"').filter(iataPkt));
});
test('observer_iata in ("SJC","SFO") matches both', () => {
assert(PF.compile('observer_iata in ("SJC","SFO")').filter(iataPkt));
assert(PF.compile('observer_iata in ("SJC","SFO")').filter(sfoPkt));
});
test('iata in ("LAX","OAK") does not match SJC', () => {
assert(!PF.compile('iata in ("LAX","OAK")').filter(iataPkt));
});
test('observer_iata contains "S"', () => {
assert(PF.compile('observer_iata contains "S"').filter(iataPkt));
assert(!PF.compile('observer_iata contains "Z"').filter(iataPkt));
});
test('missing observer_iata → no match (not parse error)', () => {
const c = PF.compile('observer_iata == "SJC"');
assert(c.error === null, 'should parse with no error');
assert(!c.filter(noIataPkt), 'should not match when iata absent');
});
test('combined: type == ADVERT && iata == "SJC"', () => {
const advIataPkt = { ...iataPkt, payload_type: 4 };
assert(PF.compile('type == ADVERT && iata == "SJC"').filter(advIataPkt));
});
test('observer_iata and iata appear in suggest field list', () => {
const names = PF.FIELDS.map(f => f.name);
assert(names.indexOf('observer_iata') !== -1, 'observer_iata in FIELDS');
assert(names.indexOf('iata') !== -1, 'iata in FIELDS');
});
console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`);
process.exit(fail > 0 ? 1 : 0);