From c70f1d0fcd461e927ee373149d420c6c102cff0b Mon Sep 17 00:00:00 2001 From: efiten Date: Fri, 16 Oct 2026 17:00:25 +0200 Subject: [PATCH] feat(rx-coverage): Uplink layer, did the mesh hear the companion The ingestor stores CoreDrive RX uplink pings since b146a878 (#20). This serves and draws them. - GET /api/uplink-coverage (cmd/server/uplink_coverage.go), gated on clientUplink.enabled. Per ping the class is: both the companion heard a repeater re-broadcast it AND an observer received it (transmissions.hash = the ping's hash) observed an observer received it, no echo echo echo, no observer received it none neither The hash comes from the ping (echo) or from client_uplink_tx (the ingestor's own decryption). Per hex cell: counts per class, best class, first-hop repeaters (max 12), most observers reached, longest path, last ping. One arrivals query per 500 hashes; empty collection while the tables do not exist yet. Documented in openapi.go. - /api/config/client exposes clientUplink; roles.js sets MC_CLIENT_UPLINK. - rx-coverage.js: "Uplink" button in the layer bar (also without clientRfSamples), legend with the four classes and the caveat that "not received by an observer" can mean no observer in range, tooltip per cell, layer=uplink deep link. Colours as theme tokens (--nq-up-*, light and dark). Tests: Go (class mapping, per-cell aggregation, endpoint with echo-hash and tx-hash pings, days window, no-position pings left out, 404 when off, 400 on bad bbox, empty when tables absent), unit test for colour and tooltip, E2E test-rx-coverage-uplink-e2e.js (no button when off, cells and legend, deep link). Full server suite and all test-all.sh unit suites pass; XSS gate passes on rx-coverage.js and roles.js. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 1 + cmd/server/config.go | 14 ++ cmd/server/openapi.go | 6 + cmd/server/routes.go | 2 + cmd/server/types.go | 1 + cmd/server/uplink_coverage.go | 292 +++++++++++++++++++++++ cmd/server/uplink_coverage_test.go | 144 +++++++++++ docs/client-rx-coverage.md | 6 + public/node-reach-coverage.css | 9 + public/roles.js | 1 + public/rx-coverage.js | 75 +++++- scripts/non-unit-tests.json | 1 + test-all.sh | 1 + tests/e2e/test-rx-coverage-uplink-e2e.js | 83 +++++++ tests/unit/test-rx-coverage-uplink.js | 47 ++++ 15 files changed, 676 insertions(+), 7 deletions(-) create mode 100644 cmd/server/uplink_coverage.go create mode 100644 cmd/server/uplink_coverage_test.go create mode 100644 tests/e2e/test-rx-coverage-uplink-e2e.js create mode 100644 tests/unit/test-rx-coverage-uplink.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4a364be26..fe116305c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -678,6 +678,7 @@ jobs: suite 2 test-neighbor-map-btn-clip-e2e.js CHROMIUM_REQUIRE=1 suite 3 test-rx-coverage-viewport-e2e.js BASE_URL=$B suite 1 test-rx-coverage-noise-e2e.js BASE_URL=$B + suite 1 test-rx-coverage-uplink-e2e.js BASE_URL=$B # Issue #2037: these eight sat in tests/e2e/ with no runner at all. # Measured on upstream master in CI run 35340835178: all eight pass, # 45 seconds together. test-rx-coverage-mobile-nav-e2e.js is the diff --git a/cmd/server/config.go b/cmd/server/config.go index acc3315bc..dd0c9181b 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -185,6 +185,10 @@ type Config struct { // off; see ClientRfSamplesEnabled. ClientRfSamples *ClientRfSamplesConfig `json:"clientRfSamples,omitempty"` + // ClientUplink gates the uplink ping layer (cmd/server/uplink_coverage.go). + // Same JSON section as the ingestor's ClientUplinkConfig; absent ⇒ off. + ClientUplink *ClientUplinkConfig `json:"clientUplink,omitempty"` + // UserManagement gates optional accounts // (docs/specs/2026-10-06-user-management-design.md). Absent/nil means off; // see UserManagementEnabled. @@ -318,6 +322,16 @@ type ClientRfSamplesConfig struct { Enabled bool `json:"enabled"` } +// ClientUplinkConfig mirrors cmd/ingestor's ClientUplinkConfig. +type ClientUplinkConfig struct { + Enabled bool `json:"enabled"` +} + +// ClientUplinkEnabled reports whether the uplink layer is served. +func (c *Config) ClientUplinkEnabled() bool { + return c != nil && c.ClientUplink != nil && c.ClientUplink.Enabled +} + // ClientRfSamplesEnabled reports whether the opt-in RF environment sample // stream is on. Nil config or absent/nil section ⇒ off (the safe default), // mirroring ClientRxCoverageEnabled. diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 3ae41e277..259f6adf9 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -160,6 +160,12 @@ func routeDescriptions() map[string]routeMeta { {Name: "z", Description: "Leaflet zoom level; sets the hex resolution", Type: "integer"}, {Name: "days", Description: "Look-back window in days (1-30, default 7)", Type: "integer"}, }}, + "GET /api/uplink-coverage": {Summary: "Uplink ping hex grid", Description: "GeoJSON hex cells where mobile companions sent uplink pings (CoreDrive RX, client_uplink_pings). Per cell: pings, and how many were `both` (a repeater re-broadcast was heard by the companion AND an observer received the ping), `observed` (observer only), `echo` (re-broadcast heard, no observer received it) or `none`; `best` is the best class in the cell, plus the first-hop repeaters, the most observers reached and the longest path. `observed` depends on observer placement: a ping no observer received may still have travelled. Gated on clientUplink.", Tag: "coverage", + QueryParams: []paramMeta{ + {Name: "bbox", Description: "Bounding box as minLat,minLon,maxLat,maxLon", Type: "string", Required: true}, + {Name: "z", Description: "Leaflet zoom level; sets the hex resolution", Type: "integer"}, + {Name: "days", Description: "Look-back window in days (1-30, default 7)", Type: "integer"}, + }}, "GET /api/rx-coverage": {Summary: "Mobile RX coverage hex grid", Description: "GeoJSON hex cells where mobile clients directly heard nodes (client_receptions): count, best SNR and the nodes heard per cell. With gaps=1 the payload also carries a `gaps` member: cells a companion drove through (moving client_rf_samples) with no reception in the same window, each with its sample count, plus `gaps_truncated` when capped. Gaps are only returned without a node filter and with clientRfSamples on; they mean nothing was received in this period, not that there is no coverage. Gated on clientRxCoverage.", Tag: "coverage", QueryParams: []paramMeta{ {Name: "bbox", Description: "Bounding box as minLat,minLon,maxLat,maxLon", Type: "string", Required: true}, diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 6805c41d0..d852da54f 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -348,6 +348,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { // Same registered-unconditionally / 404-when-off pattern as coverage above, // gated by requireClientRfSamples instead. r.HandleFunc("/api/rf-noise", s.handleRfNoise).Methods("GET") + r.HandleFunc("/api/uplink-coverage", s.handleUplinkCoverage).Methods("GET") // gated by requireClientUplink r.HandleFunc("/api/nodes/resolve", s.handleResolvePrefix).Methods("GET") r.HandleFunc("/api/nodes/reported", s.handleReportedNodes).Methods("GET") r.HandleFunc("/api/rx-coverage", s.handleRxCoverage).Methods("GET") @@ -550,6 +551,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) { Customizer: CustomizerClientConfig{DisabledTabs: disabledTabs}, ClientRxCoverage: s.cfg.ClientRxCoverageEnabled(), ClientRfSamples: s.cfg.ClientRfSamplesEnabled(), + ClientUplink: s.cfg.ClientUplinkEnabled(), ClientRxRequireLinkedCompanion: s.auth != nil && s.cfg.ClientRxRequireLinkedCompanionSet(), PathTrust: &pathTrust, UserManagement: s.clientUserManagement(), diff --git a/cmd/server/types.go b/cmd/server/types.go index 08acf1b92..8a4bc1e2a 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -1086,6 +1086,7 @@ type ClientConfigResponse struct { Customizer CustomizerClientConfig `json:"customizer"` ClientRxCoverage bool `json:"clientRxCoverage"` ClientRfSamples bool `json:"clientRfSamples"` + ClientUplink bool `json:"clientUplink"` // Present (true) only when clientRxCoverage.requireLinkedCompanion is set // and user management is on: CoreDrive RX then holds its queue until // its companion is linked. diff --git a/cmd/server/uplink_coverage.go b/cmd/server/uplink_coverage.go new file mode 100644 index 000000000..2320699c4 --- /dev/null +++ b/cmd/server/uplink_coverage.go @@ -0,0 +1,292 @@ +package main + +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +// Uplink coverage: per hex cell, whether the mesh heard the mobile companions +// that pinged there. A ping is one "cdrx " channel message from CoreDrive +// RX (cmd/ingestor/client_uplink.go stores it). Two facts decide its class: +// +// echo the companion heard a repeater re-broadcast the ping +// observed a CoreScope observer received the ping (by its packet hash) +// +// "observed" depends on where observers stand: a ping no observer received +// may still have travelled. The legend says so; the data cannot. + +const ( + uplinkBoth = "both" // echo and observed + uplinkObserved = "observed" // observed, no echo heard + uplinkEcho = "echo" // echo, no observer received it + uplinkNone = "none" // neither +) + +// uplinkRank orders classes from best to worst for a cell's summary colour. +var uplinkRank = map[string]int{uplinkBoth: 3, uplinkObserved: 2, uplinkEcho: 1, uplinkNone: 0} + +// uplinkFeatureCap bounds the response like the other coverage layers. +const uplinkFeatureCap = 5000 + +// uplinkMaxFirstHops bounds the first-hop list per cell. +const uplinkMaxFirstHops = 12 + +type uplinkPingRow struct { + Lat, Lon float64 + SentAt string + EchoCount int + FirstHops string // comma-separated, from the ingestor + PktHash string // from the echo, else from client_uplink_tx; "" when unknown +} + +type uplinkArrival struct { + Observers int + MaxHops int +} + +func uplinkClass(echo, observed bool) string { + switch { + case echo && observed: + return uplinkBoth + case observed: + return uplinkObserved + case echo: + return uplinkEcho + } + return uplinkNone +} + +type UplinkFeatureCollection struct { + Type string `json:"type"` // "FeatureCollection" + Features []UplinkFeature `json:"features"` + Truncated bool `json:"truncated,omitempty"` +} + +type UplinkFeature struct { + Type string `json:"type"` // "Feature" + Geometry CoveragePolygon `json:"geometry"` + Properties UplinkProperties `json:"properties"` +} + +// UplinkProperties summarises the pings in one cell. +type UplinkProperties struct { + Cell string `json:"cell"` + Pings int `json:"pings"` + Both int `json:"both"` + Observed int `json:"observed"` + Echo int `json:"echo"` + None int `json:"none"` + Best string `json:"best"` + FirstHops []string `json:"first_hops"` + MaxObservers int `json:"max_observers"` + MaxHops int `json:"max_hops"` + LastAt string `json:"last_at"` +} + +func aggregateUplink(rows []uplinkPingRow, arrivals map[string]uplinkArrival, res int) UplinkFeatureCollection { + type agg struct { + p UplinkProperties + hops map[string]bool + } + byCell := map[string]*agg{} + for _, r := range rows { + cell := hexCellAt(r.Lat, r.Lon, res) + a := byCell[cell] + if a == nil { + a = &agg{p: UplinkProperties{Cell: cell, Best: uplinkNone, FirstHops: []string{}}, hops: map[string]bool{}} + byCell[cell] = a + } + arr := arrivals[r.PktHash] + class := uplinkClass(r.EchoCount > 0, r.PktHash != "" && arr.Observers > 0) + a.p.Pings++ + switch class { + case uplinkBoth: + a.p.Both++ + case uplinkObserved: + a.p.Observed++ + case uplinkEcho: + a.p.Echo++ + default: + a.p.None++ + } + if uplinkRank[class] > uplinkRank[a.p.Best] { + a.p.Best = class + } + if arr.Observers > a.p.MaxObservers { + a.p.MaxObservers = arr.Observers + } + if arr.MaxHops > a.p.MaxHops { + a.p.MaxHops = arr.MaxHops + } + if r.SentAt > a.p.LastAt { + a.p.LastAt = r.SentAt + } + for _, h := range strings.Split(r.FirstHops, ",") { + if h != "" && !a.hops[h] && len(a.p.FirstHops) < uplinkMaxFirstHops { + a.hops[h] = true + a.p.FirstHops = append(a.p.FirstHops, h) + } + } + } + fc := UplinkFeatureCollection{Type: "FeatureCollection", Features: []UplinkFeature{}} + for cell, a := range byCell { + ring := hexBoundary(cell) + if ring == nil { + continue + } + fc.Features = append(fc.Features, UplinkFeature{ + Type: "Feature", + Geometry: CoveragePolygon{Type: "Polygon", Coordinates: [][][2]float64{ring}}, + Properties: a.p, + }) + } + if len(fc.Features) > uplinkFeatureCap { + sort.Slice(fc.Features, func(i, j int) bool { + pi, pj := fc.Features[i].Properties.Pings, fc.Features[j].Properties.Pings + if pi != pj { + return pi > pj + } + return fc.Features[i].Properties.Cell < fc.Features[j].Properties.Cell + }) + fc.Features = fc.Features[:uplinkFeatureCap] + fc.Truncated = true + } + sort.Slice(fc.Features, func(i, j int) bool { return fc.Features[i].Properties.Cell < fc.Features[j].Properties.Cell }) + return fc +} + +// queryUplinkPings returns pings with a position inside the bbox, within +// `days` (0 = all time). The packet hash comes from the ping when it had an +// echo, otherwise from the ingestor's own tag -> hash link. +func (s *Server) queryUplinkPings(days int, b bbox) ([]uplinkPingRow, error) { + where := []string{"p.lat IS NOT NULL", "p.lat BETWEEN ? AND ?", "p.lon BETWEEN ? AND ?"} + args := []interface{}{b.MinLat, b.MaxLat, b.MinLon, b.MaxLon} + if days > 0 { + where = append(where, "p.sent_at >= ?") + args = append(args, time.Now().UTC().AddDate(0, 0, -days).Format(rxTimeMillisLayoutServer)) + } + rows, err := s.db.conn.Query(` + SELECT p.lat, p.lon, p.sent_at, p.echo_count, p.first_hops, COALESCE(p.pkt_hash, t.pkt_hash, '') + FROM client_uplink_pings p + LEFT JOIN client_uplink_tx t ON t.tag = p.tag + WHERE `+strings.Join(where, " AND "), args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []uplinkPingRow{} + for rows.Next() { + var r uplinkPingRow + if err := rows.Scan(&r.Lat, &r.Lon, &r.SentAt, &r.EchoCount, &r.FirstHops, &r.PktHash); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// queryUplinkArrivals counts, per packet hash, the distinct observers that +// received it and the longest path they saw. One query per 500 hashes. +func (s *Server) queryUplinkArrivals(rows []uplinkPingRow) (map[string]uplinkArrival, error) { + seen := map[string]bool{} + var hashes []string + for _, r := range rows { + if r.PktHash != "" && !seen[r.PktHash] { + seen[r.PktHash] = true + hashes = append(hashes, r.PktHash) + } + } + out := make(map[string]uplinkArrival, len(hashes)) + observerCol := "o.observer_idx" + if !s.db.isV3 { + observerCol = "o.observer_id" + } + const chunk = 500 + for i := 0; i < len(hashes); i += chunk { + end := i + chunk + if end > len(hashes) { + end = len(hashes) + } + batch := hashes[i:end] + args := make([]interface{}, len(batch)) + for j, h := range batch { + args[j] = h + } + q, err := s.db.conn.Query(` + SELECT t.hash, COUNT(DISTINCT `+observerCol+`), MAX(json_array_length(o.path_json)) + FROM transmissions t JOIN observations o ON o.transmission_id = t.id + WHERE t.hash IN (`+sqlPlaceholders(len(batch))+`) + GROUP BY t.hash`, args...) + if err != nil { + return nil, err + } + for q.Next() { + var h string + var a uplinkArrival + var hops *int + if err := q.Scan(&h, &a.Observers, &hops); err != nil { + q.Close() + return nil, err + } + if hops != nil { + a.MaxHops = *hops + } + out[h] = a + } + q.Close() + } + return out, nil +} + +// rxTimeMillisLayoutServer matches the ingestor's sent_at format +// (rxTimeMillisLayout in cmd/ingestor), so the cutoff compares +// lexicographically against stored values. +const rxTimeMillisLayoutServer = "2006-01-02T15:04:05.000Z07:00" + +func (s *Server) requireClientUplink(w http.ResponseWriter, r *http.Request) bool { + if s == nil || s.cfg == nil || !s.cfg.ClientUplinkEnabled() { + http.NotFound(w, r) + return false + } + return true +} + +// handleUplinkCoverage serves the uplink layer as GeoJSON hex cells. An +// instance whose ingestor has not created the tables yet answers an empty +// collection. +func (s *Server) handleUplinkCoverage(w http.ResponseWriter, r *http.Request) { + if !s.requireClientUplink(w, r) { + return + } + b, ok := parseBBox(r.URL.Query().Get("bbox")) + if !ok { + http.Error(w, "bbox required as minLat,minLon,maxLat,maxLon", http.StatusBadRequest) + return + } + if s.db == nil || s.db.conn == nil { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + days := clampDays(atoiDefault(r.URL.Query().Get("days"), 7)) + z, _ := strconv.Atoi(r.URL.Query().Get("z")) + fc := UplinkFeatureCollection{Type: "FeatureCollection", Features: []UplinkFeature{}} + if s.db.tablePresent("client_uplink_pings") && s.db.tablePresent("client_uplink_tx") { + rows, err := s.queryUplinkPings(days, b) + if err != nil { + http.Error(w, "query failed", http.StatusInternalServerError) + return + } + arrivals, err := s.queryUplinkArrivals(rows) + if err != nil { + http.Error(w, "query failed", http.StatusInternalServerError) + return + } + fc = aggregateUplink(rows, arrivals, zoomToHexRes(z)) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(fc) +} diff --git a/cmd/server/uplink_coverage_test.go b/cmd/server/uplink_coverage_test.go new file mode 100644 index 000000000..0af6ed670 --- /dev/null +++ b/cmd/server/uplink_coverage_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" +) + +func TestUplinkClass(t *testing.T) { + cases := map[[2]bool]string{ + {true, true}: uplinkBoth, {false, true}: uplinkObserved, {true, false}: uplinkEcho, {false, false}: uplinkNone, + } + for in, want := range cases { + if got := uplinkClass(in[0], in[1]); got != want { + t.Errorf("uplinkClass(echo=%v, observed=%v) = %s, want %s", in[0], in[1], got, want) + } + } +} + +func TestAggregateUplinkCountsClassesPerCell(t *testing.T) { + rows := []uplinkPingRow{ + {Lat: 50.998, Lon: 5.384, SentAt: "2026-10-16T14:51:15.018Z", EchoCount: 3, FirstHops: "c0cd,1403", PktHash: "aaaa"}, + {Lat: 50.998, Lon: 5.384, SentAt: "2026-10-16T14:02:45.000Z", EchoCount: 0, FirstHops: "", PktHash: ""}, + {Lat: 50.998, Lon: 5.3841, SentAt: "2026-10-16T15:00:00.000Z", EchoCount: 1, FirstHops: "1403", PktHash: "bbbb"}, + {Lat: 51.2, Lon: 5.6, SentAt: "2026-10-16T15:10:00.000Z", EchoCount: 0, FirstHops: "", PktHash: "cccc"}, + } + arrivals := map[string]uplinkArrival{"aaaa": {Observers: 17, MaxHops: 11}, "cccc": {Observers: 2, MaxHops: 3}} + fc := aggregateUplink(rows, arrivals, 9) + if len(fc.Features) != 2 { + t.Fatalf("cells = %d, want 2", len(fc.Features)) + } + byCell := map[string]UplinkProperties{} + for _, f := range fc.Features { + byCell[f.Properties.Cell] = f.Properties + } + home := byCell[hexCellAt(50.998, 5.384, 9)] + if home.Pings != 3 || home.Both != 1 || home.Echo != 1 || home.None != 1 || home.Observed != 0 { + t.Fatalf("home cell = %+v", home) + } + if home.Best != uplinkBoth || home.MaxObservers != 17 || home.MaxHops != 11 { + t.Errorf("home summary = %+v", home) + } + if len(home.FirstHops) != 2 || home.FirstHops[0] != "c0cd" || home.FirstHops[1] != "1403" { + t.Errorf("first hops = %v, want distinct c0cd,1403", home.FirstHops) + } + if home.LastAt != "2026-10-16T15:00:00.000Z" { + t.Errorf("last_at = %s", home.LastAt) + } + far := byCell[hexCellAt(51.2, 5.6, 9)] + if far.Observed != 1 || far.Best != uplinkObserved { + t.Errorf("far cell = %+v, want observed only", far) + } +} + +func seedUplinkDB(t *testing.T) *DB { + t.Helper() + db := setupTestDBv2(t) + mustExecDB(t, db, `CREATE TABLE client_uplink_pings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, rx_pubkey TEXT NOT NULL, tag TEXT NOT NULL, sent_at TEXT NOT NULL, + ingested_at TEXT NOT NULL, channel TEXT NOT NULL DEFAULT '', scope TEXT NOT NULL DEFAULT '', pkt_hash TEXT, + echo_count INTEGER NOT NULL DEFAULT 0, first_hops TEXT NOT NULL DEFAULT '', echoes_json TEXT NOT NULL DEFAULT '[]', + lat REAL, lon REAL, pos_acc_m REAL, UNIQUE(rx_pubkey, tag))`) + mustExecDB(t, db, `CREATE TABLE client_uplink_tx (tag TEXT PRIMARY KEY, pkt_hash TEXT NOT NULL, sender TEXT NOT NULL DEFAULT '', seen_at TEXT NOT NULL)`) + recent := time.Now().UTC().Add(-time.Hour).Format(rxTimeMillisLayoutServer) + old := time.Now().UTC().AddDate(0, 0, -20).Format(rxTimeMillisLayoutServer) + // Echo plus observers: both. + mustExecDB(t, db, `INSERT INTO client_uplink_pings (rx_pubkey, tag, sent_at, ingested_at, pkt_hash, echo_count, first_hops, lat, lon) + VALUES ('aa11','78277c0e','`+recent+`','x','b76c6a2777f9b0e9',3,'1403,c0cd',50.998,5.384)`) + // No echo, hash learned by the ingestor's decrypt: observed. + mustExecDB(t, db, `INSERT INTO client_uplink_pings (rx_pubkey, tag, sent_at, ingested_at, echo_count, lat, lon) + VALUES ('aa11','11111111','`+recent+`','x',0,51.10,5.50)`) + mustExecDB(t, db, `INSERT INTO client_uplink_tx (tag, pkt_hash, seen_at) VALUES ('11111111','1111aaaa2222bbbb','x')`) + // Old ping: outside a 7-day window. + mustExecDB(t, db, `INSERT INTO client_uplink_pings (rx_pubkey, tag, sent_at, ingested_at, echo_count, lat, lon) + VALUES ('aa11','22222222','`+old+`','x',0,50.90,5.30)`) + // No position: never on the map. + mustExecDB(t, db, `INSERT INTO client_uplink_pings (rx_pubkey, tag, sent_at, ingested_at, echo_count) + VALUES ('aa11','33333333','`+recent+`','x',1)`) + for _, h := range []string{"b76c6a2777f9b0e9", "1111aaaa2222bbbb"} { + mustExecDB(t, db, `INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type) VALUES ('00','`+h+`','x',5)`) + } + mustExecDB(t, db, `INSERT INTO observations (transmission_id, observer_id, path_json, timestamp) VALUES + (1,'obsA','["1403","23C6"]',1),(1,'obsB','["1403","23C6","907C"]',1),(1,'obsA','["1403"]',1), + (2,'obsC','["5A5A"]',1)`) + return db +} + +func getUplink(t *testing.T, srv *Server, q string) (int, UplinkFeatureCollection) { + t.Helper() + router := mux.NewRouter() + router.HandleFunc("/api/uplink-coverage", srv.handleUplinkCoverage).Methods("GET") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/uplink-coverage?"+q, nil)) + var fc UplinkFeatureCollection + if rr.Code == 200 { + if err := json.Unmarshal(rr.Body.Bytes(), &fc); err != nil { + t.Fatal(err) + } + } + return rr.Code, fc +} + +func TestUplinkCoverageEndpoint(t *testing.T) { + srv := &Server{db: seedUplinkDB(t), cfg: &Config{ClientUplink: &ClientUplinkConfig{Enabled: true}}} + code, fc := getUplink(t, srv, "bbox=50,5,52,6&z=12&days=7") + if code != 200 { + t.Fatalf("status %d", code) + } + if len(fc.Features) != 2 { + t.Fatalf("cells = %d, want 2 (old and position-less pings left out)", len(fc.Features)) + } + got := map[string]UplinkProperties{} + for _, f := range fc.Features { + got[f.Properties.Best] = f.Properties + } + both, ok := got[uplinkBoth] + if !ok || both.MaxObservers != 2 || both.MaxHops != 3 { + t.Fatalf("both cell = %+v, want 2 distinct observers and 3 hops", both) + } + if obs, ok := got[uplinkObserved]; !ok || obs.MaxObservers != 1 { + t.Fatalf("observed cell (hash from client_uplink_tx) = %+v", obs) + } + if code, fc := getUplink(t, srv, "bbox=50,5,52,6&z=12&days=30"); code != 200 || len(fc.Features) != 3 { + t.Fatalf("30 days: status %d, cells %d, want 3", code, len(fc.Features)) + } +} + +func TestUplinkCoverageGatedAndTolerant(t *testing.T) { + off := &Server{db: seedUplinkDB(t), cfg: &Config{}} + if code, _ := getUplink(t, off, "bbox=50,5,52,6"); code != 404 { + t.Fatalf("feature off: status %d, want 404", code) + } + on := &Server{db: seedUplinkDB(t), cfg: &Config{ClientUplink: &ClientUplinkConfig{Enabled: true}}} + if code, _ := getUplink(t, on, "bbox=nope"); code != 400 { + t.Fatalf("bad bbox: status %d, want 400", code) + } + bare := &Server{db: setupTestDBv2(t), cfg: &Config{ClientUplink: &ClientUplinkConfig{Enabled: true}}} + if code, fc := getUplink(t, bare, "bbox=50,5,52,6"); code != 200 || len(fc.Features) != 0 { + t.Fatalf("no tables yet: status %d, cells %d, want 200 and none", code, len(fc.Features)) + } +} diff --git a/docs/client-rx-coverage.md b/docs/client-rx-coverage.md index 92a314e8c..8856d0ec9 100644 --- a/docs/client-rx-coverage.md +++ b/docs/client-rx-coverage.md @@ -236,6 +236,12 @@ listens 10 s for repeaters re-broadcasting it, and publishes the ping here. Acce - Echoes keep their first hop (the repeater that heard the companion) and last hop (the one the companion heard it from). Invalid echoes are dropped; at most 32 are kept. - The broker ACL must allow `/uplink`. +- **On the map:** the RX coverage page gets an **Uplink** layer (`GET /api/uplink-coverage`) when + `clientUplink.enabled` is set. Each hex shows the best result of the pings sent there: + *both directions* (a repeater re-broadcast was heard and an observer received the ping), + *heard by observers*, *repeater only* (no observer received it), or *not heard*. The tooltip lists + the repeaters that picked a ping up (first hops), the most observers reached and the longest path. + "Not received by an observer" can also mean no observer is in range there. ## Capture HARD RULE — only what was heard directly diff --git a/public/node-reach-coverage.css b/public/node-reach-coverage.css index dec1692b8..e93a25af2 100644 --- a/public/node-reach-coverage.css +++ b/public/node-reach-coverage.css @@ -11,6 +11,11 @@ --nq-cov-weak: #e74c3c; /* SF8: < −9 dB (poor, packet loss likely) */ --nq-cov-grey: #95a5a6; /* heard, no SNR metric */ --nq-cov-gap: #57606a; /* hatch: driven through, nothing received */ + /* Uplink layer (rx-coverage.js drawUplinkLayer): did the mesh hear the companion */ + --nq-up-both: #2ecc71; /* a repeater echoed it AND an observer received it */ + --nq-up-observed: #2f80ed; /* an observer received it, no echo heard */ + --nq-up-echo: #e6a817; /* a repeater echoed it, no observer received it */ + --nq-up-none: #c0392b; /* neither */ } /* Dark-theme variants: the saturated mid-luminance defaults glare on dark basemaps. Mirrors the rest of the dashboard's theme-aware tokens (#polish). */ @@ -20,6 +25,10 @@ --nq-cov-weak: #f85149; --nq-cov-grey: #8b949e; --nq-cov-gap: #c9d1d9; + --nq-up-both: #3fb950; + --nq-up-observed: #58a6ff; + --nq-up-echo: #d29922; + --nq-up-none: #f85149; } .nq-cov-legend { display:flex; gap:12px; align-items:center; font-size:11px; color:var(--text-muted, #57606a); margin:4px 0 10px; } /* Toggled by node-reach.js applyCoverage via class, not inline style, so CSS diff --git a/public/roles.js b/public/roles.js index e976e12df..c3f2f27af 100644 --- a/public/roles.js +++ b/public/roles.js @@ -593,6 +593,7 @@ window.MeshConfigReady = fetch('/api/config/client').then(function (r) { return r.json(); }).then(function (cfg) { window.MC_CLIENT_RX_COVERAGE = cfg.clientRxCoverage === true; window.MC_CLIENT_RF_SAMPLES = cfg.clientRfSamples === true; + window.MC_CLIENT_UPLINK = cfg.clientUplink === true; // Coverage is opt-in: the nav link is NOT in static HTML (so the default-off // nav matches upstream and the nav-overflow tests). Inject it after Analytics // only when enabled, then nudge applyNavPriority (it re-runs on 'resize'). diff --git a/public/rx-coverage.js b/public/rx-coverage.js index 39c450ab4..d8265f69e 100644 --- a/public/rx-coverage.js +++ b/public/rx-coverage.js @@ -7,8 +7,9 @@ 'use strict'; (function () { var map = null, covLayer = null, days = 7, selectedRx = '', selectedName = '', boardCache = [], destroyed = false, generation = 0; - // layer: 'signal' (default, per-cell best SNR of directly-heard nodes) or - // 'noise' (RF noise-floor layer, opt-in — see MC_CLIENT_RF_SAMPLES). + // layer: 'signal' (default, per-cell best SNR of directly-heard nodes), + // 'noise' (RF noise-floor layer, opt-in — see MC_CLIENT_RF_SAMPLES) or + // 'uplink' (did the mesh hear the companion's pings — see MC_CLIENT_UPLINK). var layer = 'signal'; // showGaps: on the signal layer, also draw cells a companion drove through // with nothing received (/api/rx-coverage?gaps=1). Needs the RF sample @@ -93,7 +94,39 @@ return '--nq-cov-weak'; } + // Uplink classes, best first: the legend order and the colour of a cell's best ping. + var UPLINK_CLASSES = [ + { key: 'both', label: 'both directions', title: 'A repeater re-broadcast the ping where the companion heard it, and an observer received it' }, + { key: 'observed', label: 'heard by observers', title: 'An observer received the ping; the companion heard no re-broadcast' }, + { key: 'echo', label: 'repeater only', title: 'A repeater re-broadcast the ping, but no observer received it. Where no observer is in range this is the best a ping can show.' }, + { key: 'none', label: 'not heard', title: 'No re-broadcast heard and no observer received it' } + ]; + + function uplinkColorVar(p) { + var k = p && p.best; + return UPLINK_CLASSES.some(function (c) { return c.key === k; }) ? '--nq-up-' + k : '--nq-up-none'; + } + + function uplinkCellHtml(p) { + if (!p) return ''; + var parts = UPLINK_CLASSES.filter(function (c) { return p[c.key] > 0; }).map(function (c) { return p[c.key] + ' ' + c.label; }); + var hops = (p.first_hops || []).map(function (h) { return escapeHtml(String(h).toUpperCase()); }).join(', '); + return '
' + p.pings + (p.pings === 1 ? ' uplink ping' : ' uplink pings') + '
' + + '
' + + '
' + parts.join(' · ') + '
' + + (hops ? '
picked up by: ' + hops + '
' : '') + + (p.max_observers ? '
reached ' + p.max_observers + (p.max_observers === 1 ? ' observer' : ' observers') + (p.max_hops ? ', up to ' + p.max_hops + ' hops' : '') + '
' : '') + + '
last: ' + escapeHtml(String(p.last_at || '').slice(0, 16).replace('T', ' ')) + ' UTC
' + + '
'; + } + function legendHtml() { + if (layer === 'uplink') { + return '
' + + UPLINK_CLASSES.map(function (c) { return '' + c.label + ''; }).join('') + + '· "not heard by observers" can also mean no observer is in range' + + '
'; + } if (layer === 'noise') { return '
' + 'quiet (≤ ' + NOISE_QUIET_MAX + ' dBm)' + @@ -154,14 +187,19 @@ // must not keep claiming best-signal colouring while the noise legend (an // inverted, opposite-direction colour axis) sits right below it. function subtitleHtml() { + if (layer === 'uplink') { + return 'Where the mesh heard roaming companions: each cell shows the best result of the uplink pings sent there. Get the companion app →'; + } var colourText = layer === 'noise' ? 'Colour = median RF noise floor per cell.' : 'Colour = best signal per cell.'; return 'Where roaming CoreScope-RX clients heard nodes. ' + colourText + ' Get the companion app →'; } function pageHtml() { - var layerBar = window.MC_CLIENT_RF_SAMPLES - ? '
' + layerBtn('signal', 'Signal') + layerBtn('noise', 'Noise') + '
' + - '
' + gapsBtnHtml() + '
' + var layerBar = (window.MC_CLIENT_RF_SAMPLES || window.MC_CLIENT_UPLINK) + ? '
' + layerBtn('signal', 'Signal') + + (window.MC_CLIENT_RF_SAMPLES ? layerBtn('noise', 'Noise') : '') + + (window.MC_CLIENT_UPLINK ? layerBtn('uplink', 'Uplink') : '') + '
' + + (window.MC_CLIENT_RF_SAMPLES ? '
' + gapsBtnHtml() + '
' : '') : ''; return '
' + '

Mobile RX coverage

' + @@ -304,6 +342,27 @@ }); } + function drawUplinkLayer(bbox) { + var url = '/api/uplink-coverage?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days; + fetch(url).then(function (r) { return r.json(); }).then(function (fc) { + if (destroyed || !covLayer || layer !== 'uplink') return; + covLayer.clearLayers(); + var features = fc.features || []; + setNoiseEmpty(features.length === 0, 'No uplink pings in this view yet.'); + features.forEach(function (f) { + var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; }); + var col = cssColor(uplinkColorVar(f.properties)); + L.polygon(ring, { color: col, weight: 1, fillColor: col, fillOpacity: 0.5 }).addTo(covLayer) + .bindTooltip(uplinkCellHtml(f.properties)); + }); + }).catch(function (e) { + console.warn('rx-coverage: uplink fetch failed', e); + if (destroyed || !covLayer || layer !== 'uplink') return; + covLayer.clearLayers(); + setNoiseEmpty(true, 'Could not load uplink pings. Pan or zoom to retry.'); + }); + } + // setNoiseEmpty toggles the "no samples in this view" message. Only shown // on the noise layer — MC_CLIENT_RF_SAMPLES already keeps the toggle (and // so this layer) out of reach entirely when the feature is off, so seeing @@ -314,7 +373,7 @@ function setNoiseEmpty(show, text) { var el = document.getElementById('rxNoiseEmpty'); if (!el) return; - if (show && layer === 'noise') { + if (show && (layer === 'noise' || layer === 'uplink')) { el.textContent = text || 'No RF samples in this view yet.'; el.style.display = 'block'; } else { @@ -327,6 +386,7 @@ var b = map.getBounds(); var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(','); if (layer === 'noise') drawNoiseLayer(bbox); + else if (layer === 'uplink') drawUplinkLayer(bbox); else drawSignalLayer(bbox); } @@ -517,6 +577,7 @@ var dd = parseInt(p.get('days'), 10); if ([1, 7, 14, 30].indexOf(dd) >= 0) days = dd; selectedRx = (p.get('rx') || '').toLowerCase(); if (window.MC_CLIENT_RF_SAMPLES && p.get('layer') === 'noise') layer = 'noise'; + if (window.MC_CLIENT_UPLINK && p.get('layer') === 'uplink') layer = 'uplink'; if (p.get('gaps') === '0') showGaps = false; mine = p.get('mine') === '1'; } @@ -614,6 +675,6 @@ } if (window.addEventListener) window.addEventListener('cs-auth-changed', onAuthChanged); - window.CSRxCoverage = { _test: { coverageUrl: coverageUrl, mineBtnHtml: mineBtnHtml, authUser: authUser, coverageReply: coverageReply } }; + window.CSRxCoverage = { _test: { coverageUrl: coverageUrl, mineBtnHtml: mineBtnHtml, authUser: authUser, coverageReply: coverageReply, uplinkColorVar: uplinkColorVar, uplinkCellHtml: uplinkCellHtml } }; registerPage('rx-coverage', { init: init, destroy: destroy }); })(); diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index 0ae2d0353..38a4b3a14 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -112,6 +112,7 @@ "tests/e2e/test-rx-coverage-mobile-nav-e2e.js", "tests/e2e/test-rx-coverage-gaps-e2e.js", "tests/e2e/test-rx-coverage-noise-e2e.js", + "tests/e2e/test-rx-coverage-uplink-e2e.js", "tests/e2e/test-rx-coverage-viewport-e2e.js", "tests/e2e/test-show-neighbors.js", "tests/e2e/test-slideover-1056-e2e.js", diff --git a/test-all.sh b/test-all.sh index bbbd73d45..5bea4ccb2 100755 --- a/test-all.sh +++ b/test-all.sh @@ -203,6 +203,7 @@ node tests/unit/test-rx-coverage-config-race.js node tests/unit/test-rx-coverage-mine.js node tests/unit/test-rx-coverage-escape.js node tests/unit/test-rx-coverage-noise.js +node tests/unit/test-rx-coverage-uplink.js node tests/unit/test-rx-coverage-viewport.js node tests/unit/test-scope-audit-styles-linked.js node tests/unit/test-settings-sync.js diff --git a/tests/e2e/test-rx-coverage-uplink-e2e.js b/tests/e2e/test-rx-coverage-uplink-e2e.js new file mode 100644 index 000000000..4cf6f6b89 --- /dev/null +++ b/tests/e2e/test-rx-coverage-uplink-e2e.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +'use strict'; +// Uplink layer on the Mobile RX coverage page: the "Uplink" button exists only +// with clientUplink, asks /api/uplink-coverage, colours each cell by its best +// class, swaps in the uplink legend, and is deep-linkable (layer=uplink). +// Backend-independent: every /api/* call is mocked. +const assert = require('assert'); +const { chromium } = require('playwright'); +const BASE = process.env.BASE_URL || 'http://localhost:3000'; + +const poly = (lon, lat) => ({ type: 'Polygon', coordinates: [[[lon, lat], [lon + 0.01, lat], [lon + 0.01, lat + 0.01], [lon, lat + 0.01], [lon, lat]]] }); +const cell = (lon, lat, best) => ({ type: 'Feature', geometry: poly(lon, lat), properties: { + cell: 'u' + lon, pings: 2, both: best === 'both' ? 1 : 0, observed: best === 'observed' ? 1 : 0, echo: 0, none: 1, + best, first_hops: ['1403'], max_observers: 17, max_hops: 11, last_at: '2026-10-16T14:51:15.018Z' } }); + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.log(' ✗ ' + name + ': ' + e.message); } +} + +async function openPage(browser, { uplink, hash }) { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + const uplinkRequests = []; + await page.route('**/api/**', route => { + const url = route.request().url(); + const path = new URL(url).pathname; + if (path === '/api/config/client') return route.fulfill({ json: { clientRxCoverage: true, clientRfSamples: false, clientUplink: uplink } }); + if (path === '/api/config/map') return route.fulfill({ json: { center: [51, 5.4], zoom: 11 } }); + if (path === '/api/rx-leaderboard') return route.fulfill({ json: { observers: [] } }); + if (path === '/api/rx-coverage') return route.fulfill({ json: { type: 'FeatureCollection', features: [] } }); + if (path === '/api/uplink-coverage') { + uplinkRequests.push(url); + return route.fulfill({ json: { type: 'FeatureCollection', features: [cell(5.38, 51.0, 'both'), cell(5.45, 51.0, 'observed')] } }); + } + return route.fulfill({ json: {} }); + }); + await page.goto(BASE + '/#/rx-coverage' + (hash || '')); + await page.waitForSelector('#rxMap.leaflet-container', { timeout: 15000 }); + return { page, errors, uplinkRequests }; +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + console.log(`\n=== RX coverage uplink layer E2E against ${BASE} ===`); + + await step('no Uplink button without clientUplink', async () => { + const { page } = await openPage(browser, { uplink: false }); + assert.strictEqual(await page.$('#rxLayerBar button[data-layer="uplink"]'), null); + await page.close(); + }); + + await step('the Uplink button draws coloured cells with the uplink legend', async () => { + const { page, errors, uplinkRequests } = await openPage(browser, { uplink: true }); + await page.click('#rxLayerBar button[data-layer="uplink"]'); + await page.waitForFunction(() => document.querySelectorAll('#rxMap path.leaflet-interactive').length === 2, null, { timeout: 10000 }); + assert.ok(uplinkRequests.length >= 1 && /bbox=/.test(uplinkRequests[0]), 'requested /api/uplink-coverage with a bbox'); + const legend = await page.$eval('#rxLegend', el => el.textContent); + assert.ok(legend.includes('both directions') && legend.includes('not heard'), legend); + const fills = await page.$$eval('#rxMap path.leaflet-interactive', els => els.map(e => e.getAttribute('fill'))); + assert.notStrictEqual(fills[0], fills[1], 'both and observed cells differ in colour'); + assert.ok(/layer=uplink/.test(await page.evaluate(() => location.hash)), 'layer is in the URL'); + assert.deepStrictEqual(errors, []); + await page.close(); + }); + + await step('a layer=uplink deep link opens on the uplink layer', async () => { + const { page, uplinkRequests } = await openPage(browser, { uplink: true, hash: '?layer=uplink&days=7' }); + await page.waitForFunction(() => document.querySelectorAll('#rxMap path.leaflet-interactive').length === 2, null, { timeout: 10000 }); + assert.ok(uplinkRequests.length >= 1); + await page.close(); + }); + + await browser.close(); + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/tests/unit/test-rx-coverage-uplink.js b/tests/unit/test-rx-coverage-uplink.js new file mode 100644 index 000000000..dcbc2d20b --- /dev/null +++ b/tests/unit/test-rx-coverage-uplink.js @@ -0,0 +1,47 @@ +'use strict'; +const REPO_ROOT = require('path').resolve(__dirname, '..', '..'); +// Uplink layer (rx-coverage.js): a cell is coloured by the best class of the +// pings sent there, and its tooltip lists the classes, the repeaters that +// picked a ping up, and how far it reached. Loaded in a vm like +// test-rx-coverage-mine.js. +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const code = fs.readFileSync(path.join(REPO_ROOT, 'public', 'rx-coverage.js'), 'utf8'); +const sandbox = { + window: {}, + document: { getElementById: function () { return null; } }, + registerPage: function () {}, + console: { warn: function () {} }, + Promise: Promise, + setTimeout: function () {}, clearTimeout: function () {}, + fetch: function () { throw new Error('no fetch expected'); }, + L: {}, getComputedStyle: function () { return { getPropertyValue: function () { return ''; } }; }, + escapeHtml: function (s) { return String(s).replace(/[&<>"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; }); }, +}; +vm.createContext(sandbox); +vm.runInContext(code, sandbox); +const t = sandbox.window.CSRxCoverage._test; + +assert.strictEqual(t.uplinkColorVar({ best: 'both' }), '--nq-up-both'); +assert.strictEqual(t.uplinkColorVar({ best: 'observed' }), '--nq-up-observed'); +assert.strictEqual(t.uplinkColorVar({ best: 'echo' }), '--nq-up-echo'); +assert.strictEqual(t.uplinkColorVar({ best: 'none' }), '--nq-up-none'); +assert.strictEqual(t.uplinkColorVar({ best: 'bogus' }), '--nq-up-none', 'unknown class falls back to none'); +assert.strictEqual(t.uplinkColorVar(null), '--nq-up-none'); + +const html = t.uplinkCellHtml({ pings: 3, both: 1, observed: 0, echo: 1, none: 1, first_hops: ['c0cd', '1403'], + max_observers: 17, max_hops: 11, last_at: '2026-10-16T14:51:15.018Z' }); +assert.ok(html.includes('3 uplink pings'), html); +assert.ok(html.includes('1 both directions · 1 repeater only · 1 not heard'), html); +assert.ok(!html.includes('heard by observers'), 'zero classes are left out'); +assert.ok(html.includes('picked up by: C0CD, 1403'), html); +assert.ok(html.includes('reached 17 observers, up to 11 hops'), html); +assert.ok(html.includes('2026-10-16 14:51 UTC'), html); +const one = t.uplinkCellHtml({ pings: 1, both: 0, observed: 0, echo: 0, none: 1, first_hops: [''], max_observers: 0, last_at: '' }); +assert.ok(one.includes('1 uplink ping<') && !one.includes('reached'), one); +assert.ok(!one.includes(''), 'first hops are escaped'); + +console.log('rx-coverage uplink layer OK');