Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/server/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
43 changes: 32 additions & 11 deletions cmd/server/uplink_coverage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions cmd/server/uplink_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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))
}
Expand Down
2 changes: 1 addition & 1 deletion docs/client-rx-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion public/rx-coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<div style="font-weight:600;margin-bottom:4px">' + p.pings + (p.pings === 1 ? ' uplink ping' : ' uplink pings') + '</div>' +
'<div style="font-size:12px;line-height:1.6;min-width:180px">' +
'<div>' + parts.join(' · ') + '</div>' +
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/test-rx-coverage-uplink.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['<b>'], max_observers: 0, last_at: '' });
assert.ok(one.includes('1 uplink ping<') && !one.includes('reached'), one);
assert.ok(!one.includes('<b>'), '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 <x>', ''], max_observers: 0, last_at: '' });
assert.ok(named.includes('picked up by: BE-ZHV-ON4AZP &#60;x&#62;, 1403'), 'a resolved name replaces the prefix, escaped; an unresolved one keeps it: ' + named);

console.log('rx-coverage uplink layer OK');
Loading