diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 259f6adf9..97f3946de 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -160,7 +160,7 @@ 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", + "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 (`first_hops`, with `first_hop_names` parallel: the name, or empty when the prefix is unknown, ambiguous or hidden), 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"}, diff --git a/cmd/server/uplink_coverage.go b/cmd/server/uplink_coverage.go index 2320699c4..901c6842f 100644 --- a/cmd/server/uplink_coverage.go +++ b/cmd/server/uplink_coverage.go @@ -74,17 +74,20 @@ type UplinkFeature struct { // 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"` + 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"` + // FirstHopNames runs parallel to FirstHops: the repeater's name, or "" + // when the prefix is unknown, ambiguous or hidden (batchResolveHeardKeys). + FirstHopNames []string `json:"first_hop_names"` + 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 { @@ -247,6 +250,23 @@ func (s *Server) queryUplinkArrivals(rows []uplinkPingRow) (map[string]uplinkArr // lexicographically against stored values. const rxTimeMillisLayoutServer = "2006-01-02T15:04:05.000Z07:00" +// nameUplinkFirstHops fills FirstHopNames for every cell with one lookup for +// the distinct prefixes of the whole response. +func (s *Server) nameUplinkFirstHops(features []UplinkFeature) { + var keys []string + for _, f := range features { + keys = append(keys, f.Properties.FirstHops...) + } + names := s.batchResolveHeardKeys(keys) + for i := range features { + p := &features[i].Properties + p.FirstHopNames = make([]string, len(p.FirstHops)) + for j, h := range p.FirstHops { + p.FirstHopNames[j] = names[h][1] + } + } +} + func (s *Server) requireClientUplink(w http.ResponseWriter, r *http.Request) bool { if s == nil || s.cfg == nil || !s.cfg.ClientUplinkEnabled() { http.NotFound(w, r) @@ -286,6 +306,7 @@ func (s *Server) handleUplinkCoverage(w http.ResponseWriter, r *http.Request) { return } fc = aggregateUplink(rows, arrivals, zoomToHexRes(z)) + s.nameUplinkFirstHops(fc.Features) } 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 index 0af6ed670..2826e0287 100644 --- a/cmd/server/uplink_coverage_test.go +++ b/cmd/server/uplink_coverage_test.go @@ -85,6 +85,8 @@ func seedUplinkDB(t *testing.T) *DB { 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)`) + // 1403 resolves to one node; c0cd matches two, so it stays unnamed. + mustExecDB(t, db, `INSERT INTO nodes (public_key, name) VALUES ('1403aa', 'BE-ZHV-ON4AZP'), ('c0cd01', 'one'), ('c0cd02', 'two')`) return db } @@ -123,6 +125,9 @@ func TestUplinkCoverageEndpoint(t *testing.T) { if obs, ok := got[uplinkObserved]; !ok || obs.MaxObservers != 1 { t.Fatalf("observed cell (hash from client_uplink_tx) = %+v", obs) } + if len(both.FirstHops) != 2 || len(both.FirstHopNames) != 2 || both.FirstHopNames[0] != "BE-ZHV-ON4AZP" || both.FirstHopNames[1] != "" { + t.Fatalf("first hops %v named %q, want 1403 named and the ambiguous c0cd left empty", both.FirstHops, both.FirstHopNames) + } 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)) } diff --git a/docs/client-rx-coverage.md b/docs/client-rx-coverage.md index 8856d0ec9..dab002a1a 100644 --- a/docs/client-rx-coverage.md +++ b/docs/client-rx-coverage.md @@ -240,7 +240,7 @@ listens 10 s for repeaters re-broadcasting it, and publishes the ping here. Acce `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. + the repeaters that picked a ping up (first hops, by name when the prefix resolves to one node), 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/rx-coverage.js b/public/rx-coverage.js index d8265f69e..e9b6c31b8 100644 --- a/public/rx-coverage.js +++ b/public/rx-coverage.js @@ -110,7 +110,8 @@ 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(', '); + var names = p.first_hop_names || []; + var hops = (p.first_hops || []).map(function (h, i) { return escapeHtml(names[i] || String(h).toUpperCase()); }).join(', '); return '
' + p.pings + (p.pings === 1 ? ' uplink ping' : ' uplink pings') + '
' + '
' + '
' + parts.join(' · ') + '
' + diff --git a/tests/unit/test-rx-coverage-uplink.js b/tests/unit/test-rx-coverage-uplink.js index dcbc2d20b..a0c1da9f6 100644 --- a/tests/unit/test-rx-coverage-uplink.js +++ b/tests/unit/test-rx-coverage-uplink.js @@ -43,5 +43,8 @@ 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'); +const named = t.uplinkCellHtml({ pings: 1, both: 1, observed: 0, echo: 0, none: 0, first_hops: ['c0cd', '1403'], + first_hop_names: ['BE-ZHV-ON4AZP ', ''], max_observers: 0, last_at: '' }); +assert.ok(named.includes('picked up by: BE-ZHV-ON4AZP <x>, 1403'), 'a resolved name replaces the prefix, escaped; an unresolved one keeps it: ' + named); console.log('rx-coverage uplink layer OK');