mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-13 16:23:07 +00:00
Compare commits
7 Commits
master
...
fix/issue-1188
| Author | SHA1 | Date | |
|---|---|---|---|
| ee414b4114 | |||
| 7a3f45d9c1 | |||
| 0e09371ec3 | |||
| 7856914a56 | |||
| 4ed272761b | |||
| 2c182ebd23 | |||
| 271d72f19d |
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user