diff --git a/cmd/server/db.go b/cmd/server/db.go index de17e0c1..d02c096c 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1529,10 +1529,10 @@ type PacketPathPoint struct { Lon *float64 `json:"lon"` } -// PacketPathObserver is the station that produced the deepest observation -// of a packet path (see GetPacketPath), positioned from its configured -// IATA code the same way the Wardriving tab positions observers -- not a -// stored per-observer lat/lon column. +// PacketPathObserver is the station that produced a given branch's +// observation of a packet (see GetPacketPath), positioned from its +// configured IATA code the same way the Wardriving tab positions +// observers -- not a stored per-observer lat/lon column. type PacketPathObserver struct { Name string `json:"name"` IATA string `json:"iata,omitempty"` @@ -1540,39 +1540,50 @@ type PacketPathObserver struct { Lon *float64 `json:"lon"` } -// PacketPathResponse is the geographic relay path for one packet hash, -// used to draw it on a map (the ping-bot reply's "View path" link). -type PacketPathResponse struct { - Hash string `json:"hash"` +// PacketPathBranch is one station's route to a packet: how far it +// traveled to reach them (hop count taken straight from that +// observation's path_json, independent of how much of it resolved) and, +// where resolvable, each hop's name/role/lat/lon in path order. +type PacketPathBranch struct { Hops int `json:"hops"` Points []PacketPathPoint `json:"points"` Observer *PacketPathObserver `json:"observer,omitempty"` + SNR *float64 `json:"snr,omitempty"` } -// GetPacketPath resolves a packet's DEEPEST observation (the one with the -// most hops -- same "farthest leg" reasoning as the ping-bot reply, see -// pingBotReply's doc comment) to a geographic point sequence: each -// relay's name/role/lat/lon in path order, plus the hearing observer's -// position. A packet can have several observations (heard by more than -// one station, possibly at different hop depths); this always picks the -// one that traveled farthest, since that's the more informative path to -// show on a map. +// PacketPathResponse is every branch a packet is known to have reached -- +// one per distinct observer, kept at that observer's own deepest +// observation -- used to draw the full flood spread on a map (the +// ping-bot reply's "View path" link), not just the single farthest route. +type PacketPathResponse struct { + Hash string `json:"hash"` + Branches []PacketPathBranch `json:"branches"` +} + +// GetPacketPath resolves every distinct station that observed a packet to +// its own branch: hop count and (where resolvable) relay names/positions +// in path order, plus that station's own position. A station can hear a +// packet more than once as flood copies arrive via different routes; only +// its deepest observation (by raw hop count, same "farthest leg" +// reasoning as the ping-bot reply -- see pingBotReply's doc comment) is +// kept, so each station contributes exactly one branch. Branches are +// returned deepest-first. func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { if !db.hasResolvedPath { return nil, fmt.Errorf("resolved_path not available on this server") } var querySQL string if db.isV3 { - querySQL = `SELECT obs.name, obs.iata, o.resolved_path + querySQL = `SELECT obs.rowid, obs.name, obs.iata, o.path_json, o.resolved_path, o.snr FROM observations o JOIN transmissions t ON t.id = o.transmission_id LEFT JOIN observers obs ON obs.rowid = o.observer_idx - WHERE t.hash = ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''` + WHERE t.hash = ?` } else { - querySQL = `SELECT o.observer_name, NULL, o.resolved_path + querySQL = `SELECT o.observer_id, o.observer_name, NULL, o.path_json, o.resolved_path, o.snr FROM observations o JOIN transmissions t ON t.id = o.transmission_id - WHERE t.hash = ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''` + WHERE t.hash = ?` } rows, err := db.conn.Query(querySQL, strings.ToLower(hash)) if err != nil { @@ -1580,37 +1591,68 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { } defer rows.Close() - var bestPath []*string - var bestObserverName, bestObserverIATA sql.NullString + type obsBranch struct { + hops int + resolvedPath []*string + observerName string + observerIATA sql.NullString + snr sql.NullFloat64 + } + best := make(map[string]*obsBranch) + for rows.Next() { - var obsName, obsIATA, rpJSON sql.NullString - if err := rows.Scan(&obsName, &obsIATA, &rpJSON); err != nil { + var obsKey, obsName, obsIATA, pathJSON, resolvedPathJSON sql.NullString + var snr sql.NullFloat64 + if err := rows.Scan(&obsKey, &obsName, &obsIATA, &pathJSON, &resolvedPathJSON, &snr); err != nil { continue } - if !rpJSON.Valid { + if !pathJSON.Valid { continue } - rp := unmarshalResolvedPath(rpJSON.String) - if len(rp) > len(bestPath) { - bestPath = rp - bestObserverName, bestObserverIATA = obsName, obsIATA + var h []string + if json.Unmarshal([]byte(pathJSON.String), &h) != nil { + continue + } + hops := len(h) + var resolvedPath []*string + if resolvedPathJSON.Valid { + resolvedPath = unmarshalResolvedPath(resolvedPathJSON.String) + } + key := obsKey.String + if key == "" { + key = obsName.String + } + if key == "" { + continue // no way to attribute this observation to a station + } + if existing, ok := best[key]; !ok || hops > existing.hops { + best[key] = &obsBranch{ + hops: hops, resolvedPath: resolvedPath, + observerName: obsName.String, observerIATA: obsIATA, snr: snr, + } } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("packet path iteration: %w", err) } - resp := &PacketPathResponse{Hash: hash, Hops: len(bestPath), Points: []PacketPathPoint{}} - if len(bestPath) == 0 { + resp := &PacketPathResponse{Hash: hash, Branches: []PacketPathBranch{}} + if len(best) == 0 { return resp, nil } - pubkeys := make([]string, 0, len(bestPath)) - for _, pk := range bestPath { - if pk != nil && *pk != "" { - pubkeys = append(pubkeys, *pk) + pubkeySet := map[string]bool{} + for _, b := range best { + for _, pk := range b.resolvedPath { + if pk != nil && *pk != "" { + pubkeySet[*pk] = true + } } } + pubkeys := make([]string, 0, len(pubkeySet)) + for pk := range pubkeySet { + pubkeys = append(pubkeys, pk) + } type nodeInfo struct { name string role string @@ -1652,31 +1694,39 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { } } - for _, pk := range bestPath { - if pk == nil || *pk == "" { - continue - } - ni := nodeByPK[*pk] - name := ni.name - if name == "" { - name = *pk - } - resp.Points = append(resp.Points, PacketPathPoint{ - PublicKey: *pk, Name: name, Role: ni.role, Lat: ni.lat, Lon: ni.lon, - }) - } - - if bestObserverName.Valid && bestObserverName.String != "" { - obs := &PacketPathObserver{Name: bestObserverName.String} - if bestObserverIATA.Valid { - obs.IATA = strings.ToUpper(strings.TrimSpace(bestObserverIATA.String)) - if coord, ok := iataCoords[obs.IATA]; ok { - lat, lon := coord.Lat, coord.Lon - obs.Lat, obs.Lon = &lat, &lon + for _, b := range best { + branch := PacketPathBranch{Hops: b.hops, Points: []PacketPathPoint{}} + for _, pk := range b.resolvedPath { + if pk == nil || *pk == "" { + continue } + ni := nodeByPK[*pk] + name := ni.name + if name == "" { + name = *pk + } + branch.Points = append(branch.Points, PacketPathPoint{ + PublicKey: *pk, Name: name, Role: ni.role, Lat: ni.lat, Lon: ni.lon, + }) } - resp.Observer = obs + if b.observerName != "" { + obs := &PacketPathObserver{Name: b.observerName} + if b.observerIATA.Valid { + obs.IATA = strings.ToUpper(strings.TrimSpace(b.observerIATA.String)) + if coord, ok := iataCoords[obs.IATA]; ok { + lat, lon := coord.Lat, coord.Lon + obs.Lat, obs.Lon = &lat, &lon + } + } + branch.Observer = obs + } + if b.snr.Valid { + v := b.snr.Float64 + branch.SNR = &v + } + resp.Branches = append(resp.Branches, branch) } + sort.Slice(resp.Branches, func(i, j int) bool { return resp.Branches[i].Hops > resp.Branches[j].Hops }) return resp, nil } diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 963b0d8b..225b953a 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -622,9 +622,10 @@ func TestGetTraces(t *testing.T) { } // TestGetPacketPath covers the "View path" map data source: given a -// packet hash, resolve its DEEPEST observation's relay path to -// name/role/lat/lon per hop, plus the hearing observer's IATA-derived -// position. Deliberately independent of seedTestData's fixtures. +// packet hash, resolve every distinct station's OWN deepest observation +// (not just the single farthest one overall) to a branch of +// name/role/lat/lon per hop, plus that station's IATA-derived position. +// Deliberately independent of seedTestData's fixtures. func TestGetPacketPath(t *testing.T) { db := setupTestDB(t) defer db.Close() @@ -640,7 +641,7 @@ func TestGetPacketPath(t *testing.T) { // Shallow observation (obs1): 1 hop. db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) VALUES (1, 1, 9.0, -88, '["aa"]', '["pkAlpha"]', 1736935200)`) - // Deeper observation (obs2): 2 hops -- must win even though it's not first. + // Deeper observation (obs2): 2 hops. db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) VALUES (1, 2, 4.0, -95, '["aa","bb"]', '["pkAlpha","pkBravo"]', 1736935260)`) @@ -648,26 +649,37 @@ func TestGetPacketPath(t *testing.T) { if err != nil { t.Fatal(err) } - if resp.Hops != 2 { - t.Fatalf("Hops = %d, want 2 (the deeper observation)", resp.Hops) + if len(resp.Branches) != 2 { + t.Fatalf("Branches = %+v, want 2 (one per distinct observer)", resp.Branches) } - if len(resp.Points) != 2 { - t.Fatalf("Points = %+v, want 2 entries", resp.Points) + // Sorted deepest-first: obs2's 2-hop branch, then obs1's 1-hop branch. + deep, shallow := resp.Branches[0], resp.Branches[1] + if deep.Hops != 2 { + t.Fatalf("Branches[0].Hops = %d, want 2 (the deeper branch first)", deep.Hops) } - if resp.Points[0].Name != "RepeaterAlpha" || resp.Points[0].Lat == nil || *resp.Points[0].Lat != 56.1 { - t.Errorf("Points[0] = %+v, want RepeaterAlpha at lat 56.1", resp.Points[0]) + if len(deep.Points) != 2 { + t.Fatalf("Branches[0].Points = %+v, want 2 entries", deep.Points) } - if resp.Points[1].PublicKey != "pkBravo" || resp.Points[1].Name != "pkBravo" || resp.Points[1].Lat != nil { - t.Errorf("Points[1] = %+v, want raw pubkey fallback with nil lat (no nodes row)", resp.Points[1]) + if deep.Points[0].Name != "RepeaterAlpha" || deep.Points[0].Lat == nil || *deep.Points[0].Lat != 56.1 { + t.Errorf("Branches[0].Points[0] = %+v, want RepeaterAlpha at lat 56.1", deep.Points[0]) } - if resp.Observer == nil || resp.Observer.Name != "Observer Two" { - t.Fatalf("Observer = %+v, want Observer Two (heard the deeper observation)", resp.Observer) + if deep.Points[1].PublicKey != "pkBravo" || deep.Points[1].Name != "pkBravo" || deep.Points[1].Lat != nil { + t.Errorf("Branches[0].Points[1] = %+v, want raw pubkey fallback with nil lat (no nodes row)", deep.Points[1]) } - if resp.Observer.Lat == nil || *resp.Observer.Lat != 37.6213 { - t.Errorf("Observer.Lat = %v, want the SFO IATA coordinate (37.6213)", resp.Observer.Lat) + if deep.Observer == nil || deep.Observer.Name != "Observer Two" { + t.Fatalf("Branches[0].Observer = %+v, want Observer Two", deep.Observer) + } + if deep.Observer.Lat == nil || *deep.Observer.Lat != 37.6213 { + t.Errorf("Branches[0].Observer.Lat = %v, want the SFO IATA coordinate (37.6213)", deep.Observer.Lat) + } + if shallow.Hops != 1 || shallow.Observer == nil || shallow.Observer.Name != "Observer One" { + t.Fatalf("Branches[1] = %+v, want Observer One's 1-hop branch", shallow) } } +// TestGetPacketPath_NoResolvedPath covers a station whose observation +// never resolved: it still contributes a branch (hop count from +// path_json, so reach is never silently dropped), just with no points. func TestGetPacketPath_NoResolvedPath(t *testing.T) { db := setupTestDB(t) defer db.Close() @@ -683,11 +695,51 @@ func TestGetPacketPath_NoResolvedPath(t *testing.T) { if err != nil { t.Fatal(err) } - if len(resp.Points) != 0 { - t.Errorf("Points = %+v, want empty when no observation has a resolved_path", resp.Points) + if len(resp.Branches) != 1 { + t.Fatalf("Branches = %+v, want 1 branch even though its path never resolved", resp.Branches) } - if resp.Observer != nil { - t.Errorf("Observer = %+v, want nil when there's no resolved path", resp.Observer) + b := resp.Branches[0] + if b.Hops != 1 { + t.Errorf("Branches[0].Hops = %d, want 1 (from path_json, independent of resolution)", b.Hops) + } + if len(b.Points) != 0 { + t.Errorf("Branches[0].Points = %+v, want empty when the path never resolved", b.Points) + } + if b.Observer == nil || b.Observer.Name != "Observer One" { + t.Errorf("Branches[0].Observer = %+v, want Observer One (who heard it is always known)", b.Observer) + } +} + +// TestGetPacketPath_SameObserverMultipleObservations covers a station +// that heard the same packet more than once as later flood copies +// arrived via longer routes (the common case -- see the trace dump for +// any busy channel): only its single deepest observation should +// contribute a branch, not one branch per observation. +func TestGetPacketPath_SameObserverMultipleObservations(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkAlpha', 'RepeaterAlpha', 'repeater', 56.1, 10.2)`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('AA', 'pathtest00000003', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`) + // Direct copy arrives first (0 hops)... + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 9.0, -88, '[]', 1736935200)`) + // ...then a relayed copy arrives later, 1 hop. + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) + VALUES (1, 1, 6.0, -100, '["aa"]', '["pkAlpha"]', 1736935260)`) + + resp, err := db.GetPacketPath("pathtest00000003") + if err != nil { + t.Fatal(err) + } + if len(resp.Branches) != 1 { + t.Fatalf("Branches = %+v, want exactly 1 (same station, keep only its deepest observation)", resp.Branches) + } + if resp.Branches[0].Hops != 1 || len(resp.Branches[0].Points) != 1 { + t.Errorf("Branches[0] = %+v, want the 1-hop relayed observation, not the 0-hop direct one", resp.Branches[0]) } } @@ -699,7 +751,7 @@ func TestGetPacketPath_UnknownHash(t *testing.T) { if err != nil { t.Fatal(err) } - if resp.Hops != 0 || len(resp.Points) != 0 { + if len(resp.Branches) != 0 { t.Errorf("expected an empty response for an unknown hash, got %+v", resp) } } diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 39e832b4..4d6a9b6c 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -141,7 +141,7 @@ func routeDescriptions() map[string]routeMeta { // Misc "GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}}, "GET /api/traces/{hash}": {Summary: "Get packet traces", Description: "Returns all observer sightings for a packet hash.", Tag: "packets"}, - "GET /api/packets/{hash}/path": {Summary: "Get a packet's geographic relay path", Description: "Resolves a packet's DEEPEST observation (the one with the most hops -- same reasoning as the ping-bot reply, issue tracker: when the same flood is heard by more than one station, the farthest-along leg is the more informative one to show) to a point sequence: each relay's name/role/lat/lon in path order, plus the hearing observer's position (from its configured IATA code, like the Wardriving tab). Lat/lon are null for any hop that has never advertised a GPS position -- callers should draw a gap, not guess. Backs the Channels tab's ping-bot \"View path\" map link.", Tag: "packets", + "GET /api/packets/{hash}/path": {Summary: "Get a packet's full geographic flood spread", Description: "Resolves EVERY distinct station that observed a packet to its own branch: hop count (from that station's deepest observation) plus, where resolvable, each relay's name/role/lat/lon in path order and the station's own position (from its configured IATA code, like the Wardriving tab). A station heard more than once (later flood copies via longer routes) contributes only its deepest observation. Lat/lon are null for any hop that has never advertised a GPS position -- callers should draw a gap, not guess. Backs the Channels tab's ping-bot \"View path\" map link.", Tag: "packets", Response: schemaRef("PacketPathResponse")}, "GET /api/iata-coords": {Summary: "Get IATA airport coordinates", Description: "Returns lat/lon for known airport codes (used for observer positioning).", Tag: "config"}, "GET /api/audio-lab/buckets": {Summary: "Audio lab frequency buckets", Description: "Returns frequency bucket data for audio analysis.", Tag: "analytics"}, @@ -346,7 +346,7 @@ func componentSchemas() map[string]interface{} { }, "PacketPathObserver": map[string]interface{}{ "type": "object", - "description": "The station that produced the deepest observation of a packet path, positioned from its configured IATA code.", + "description": "The station that produced a given branch's observation of a packet path, positioned from its configured IATA code.", "properties": map[string]interface{}{ "name": str("Observer display name."), "iata": str("Observer's configured IATA airport code, when set."), @@ -354,13 +354,21 @@ func componentSchemas() map[string]interface{} { "lon": map[string]interface{}{"type": "number", "nullable": true}, }, }, + "PacketPathBranch": map[string]interface{}{ + "type": "object", + "description": "One station's own route to a packet: how far it traveled to reach them (from that observation's raw hop count, independent of how much of it resolved) and, where resolvable, each hop's position in path order.", + "properties": map[string]interface{}{ + "hops": map[string]interface{}{"type": "integer", "description": "Hop count for this station's deepest observation, taken from the raw path length -- present even when none of it resolved."}, + "points": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathPoint"), "description": "The resolvable portion of the relay path in hop order. Can be shorter than hops, or empty, when some/all hops never resolved."}, + "observer": schemaRef("PacketPathObserver"), + "snr": map[string]interface{}{"type": "number", "nullable": true, "description": "SNR of this station's deepest observation."}, + }, + }, "PacketPathResponse": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "hash": str("The packet hash this path was resolved for."), - "hops": map[string]interface{}{"type": "integer", "description": "Length of the deepest observed relay path."}, - "points": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathPoint"), "description": "The relay path in hop order."}, - "observer": schemaRef("PacketPathObserver"), + "branches": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathBranch"), "description": "One branch per distinct station that observed the packet, each kept at that station's own deepest observation, sorted deepest-first -- shows the full flood spread, not just the single farthest route."}, }, }, } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index a757e912..d88fca6c 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3156,7 +3156,7 @@ func (s *Server) handleTraces(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePacketPath(w http.ResponseWriter, r *http.Request) { hash := mux.Vars(r)["hash"] if s.db == nil { - writeJSON(w, PacketPathResponse{Hash: hash, Points: []PacketPathPoint{}}) + writeJSON(w, PacketPathResponse{Hash: hash, Branches: []PacketPathBranch{}}) return } resp, err := s.db.GetPacketPath(hash) diff --git a/public/channels.js b/public/channels.js index fe9c1942..7316f6bb 100644 --- a/public/channels.js +++ b/public/channels.js @@ -2334,10 +2334,11 @@ // "Not sent to the mesh" caveat is load-bearing, not decoration -- // without it this could be misread as a real bot reply the sender's // own radio received. - // "View path" only makes sense when there's an actual multi-hop - // route to draw (hops > 0) and we have a packet hash to look it up - // by -- a direct (0-hop) reply has no relay path to show on a map. - const viewPathHtml = (msg.botReply && msg.botReply.hops > 0 && msg.packetHash) + // "View path" needs a packet hash to look up. Shown even for a + // 0-hop reply -- the map now plots every station that heard the + // packet (not just the deepest relay chain), so a direct-only ping + // still has a spread worth visualizing (see GetPacketPath). + const viewPathHtml = (msg.botReply && msg.packetHash) ? ` · ` : ''; const botReplyHtml = msg.botReply ? `
'; @@ -66,16 +87,22 @@ return; } - var allHops = data.points || []; - var located = allHops.filter(function (p) { return p.lat != null && p.lon != null; }); - var missing = allHops.length - located.length; - var hasObserver = !!(data.observer && data.observer.lat != null && data.observer.lon != null); + var branches = data.branches || []; + var plotted = branches.map(function (b, i) { + var built = chainForBranch(b); + return { branch: b, chain: built.chain, missing: built.missing, primary: i === 0 }; + }).filter(function (p) { return p.chain.length > 0; }); - if (located.length === 0 && !hasObserver) { + if (plotted.length === 0) { if (statusEl) { - statusEl.textContent = data.hops > 0 - ? 'None of the ' + data.hops + ' hop' + (data.hops === 1 ? '' : 's') + ' in this path have a known position yet.' - : 'This packet has no resolved relay path yet.'; + if (branches.length === 0) { + statusEl.textContent = 'This packet has no observations yet.'; + } else { + var deepestUnplottable = branches[0].hops; + statusEl.textContent = 'None of the ' + branches.length + ' station' + (branches.length === 1 ? '' : 's') + + ' that heard this packet have a known position yet (farthest reached ' + + deepestUnplottable + ' hop' + (deepestUnplottable === 1 ? '' : 's') + ').'; + } } return; } @@ -85,14 +112,8 @@ return; } - var chain = located.map(function (p, i) { - return { lat: p.lat, lon: p.lon, name: p.name, label: 'hop ' + (i + 1) + ' of ' + data.hops }; - }); - if (hasObserver) { - chain.push({ lat: data.observer.lat, lon: data.observer.lon, name: data.observer.name, label: 'observer', isObserver: true }); - } - - var center = chain[Math.floor(chain.length / 2)]; + var primaryChain = plotted[0].chain; + var center = primaryChain[Math.floor(primaryChain.length / 2)]; var map = L.map('packetPathMapContainer', { zoomControl: true, attributionControl: false }) .setView([center.lat, center.lon], 10); if (typeof window._applyTilesToNodeMap === 'function') { @@ -104,26 +125,42 @@ var outline = cssVar('--surface-0'); var accent = cssVar('--accent'); var observerColor = cssVar('--status-yellow'); + var muted = cssVar('--text-muted'); var bounds = []; - var line = []; - chain.forEach(function (p) { - bounds.push([p.lat, p.lon]); - line.push([p.lat, p.lon]); - var color = p.isObserver ? observerColor : accent; - L.circleMarker([p.lat, p.lon], { radius: p.isObserver ? 7 : 6, color: outline, weight: 2, fillColor: color, fillOpacity: 1 }) - .addTo(map) - .bindTooltip(escapeHtml(p.name) + ' (' + p.label + ')'); + var missingTotal = 0; + // Draw secondary branches first so the primary (deepest) one ends up on top. + var ordered = plotted.slice().sort(function (a, b) { return (a.primary ? 1 : 0) - (b.primary ? 1 : 0); }); + ordered.forEach(function (p) { + missingTotal += p.missing; + var lineColor = p.primary ? accent : muted; + var line = []; + p.chain.forEach(function (pt) { + bounds.push([pt.lat, pt.lon]); + line.push([pt.lat, pt.lon]); + var color = pt.isObserver ? observerColor : lineColor; + var radius = p.primary ? (pt.isObserver ? 7 : 6) : (pt.isObserver ? 5 : 4); + L.circleMarker([pt.lat, pt.lon], { + radius: radius, color: outline, weight: p.primary ? 2 : 1, + fillColor: color, fillOpacity: p.primary ? 1 : 0.8, + }) + .addTo(map) + .bindTooltip(escapeHtml(pt.name) + ' (' + pt.label + ')'); + }); + if (line.length > 1) { + L.polyline(line, { color: lineColor, weight: p.primary ? 2.5 : 1.5, opacity: p.primary ? 0.85 : 0.5 }).addTo(map); + } }); - if (line.length > 1) { - L.polyline(line, { color: accent, weight: 2.5, opacity: 0.85 }).addTo(map); - } try { map.fitBounds(bounds, { padding: [30, 30] }); } catch (e) { /* single point */ } setTimeout(function () { map.invalidateSize(); }, 120); activeMap = map; - var statusParts = [data.hops + ' hop' + (data.hops === 1 ? '' : 's') + ' total']; - if (missing > 0) statusParts.push(missing + ' without a known position (not shown)'); + var deepestHops = branches[0].hops; + var statusParts = [ + plotted.length + ' of ' + branches.length + ' station' + (branches.length === 1 ? '' : 's') + ' shown', + 'deepest reached ' + deepestHops + ' hop' + (deepestHops === 1 ? '' : 's'), + ]; + if (missingTotal > 0) statusParts.push(missingTotal + ' hop' + (missingTotal === 1 ? '' : 's') + ' without a known position (not shown)'); if (statusEl) statusEl.textContent = statusParts.join(' · '); } diff --git a/test-channels-ping-bot-reply.js b/test-channels-ping-bot-reply.js index 8797f34f..4a98b9fe 100644 --- a/test-channels-ping-bot-reply.js +++ b/test-channels-ping-bot-reply.js @@ -162,7 +162,7 @@ test('a message with botReply renders a distinct bot bubble with the reply text' assert.ok(html.includes('SNR 8.2dB'), 'should include the SNR from the reply text'); }); -test('"View path" link appears when hops > 0 and a packetHash is available', () => { +test('"View path" link appears when a packetHash is available', () => { const { ctx, chMessagesEl } = makeSandbox(); ctx.window._channelsSetStateForTest({ messages: [ { @@ -176,7 +176,7 @@ test('"View path" link appears when hops > 0 and a packetHash is available', () assert.ok(html.includes('data-view-path="abc123"'), 'should carry the packet hash for the click handler to look up'); }); -test('"View path" link is absent for a direct (0-hop) reply -- nothing to draw', () => { +test('"View path" link still appears for a direct (0-hop) reply -- the map plots every station that heard it, not just relay hops', () => { const { ctx, chMessagesEl } = makeSandbox(); ctx.window._channelsSetStateForTest({ messages: [ { @@ -185,7 +185,7 @@ test('"View path" link is absent for a direct (0-hop) reply -- nothing to draw', }, ] }); ctx.window._channelsRenderMessagesForTest(); - assert.ok(!chMessagesEl.innerHTML.includes('View path'), 'a direct reply has no relay path to visualize'); + assert.ok(chMessagesEl.innerHTML.includes('View path'), 'a direct reply still has observer positions worth visualizing'); }); test('"View path" link is absent when there is no packetHash to look it up by', () => { diff --git a/test-packet-path-map.js b/test-packet-path-map.js index 1f63ef3a..78aaa32c 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -37,7 +37,15 @@ test('fetches via the shared api() helper, not a raw fetch (picks up auth/base-U }); test('escapes node/observer names before interpolating into tooltip HTML (operator-controlled data)', () => { - assert.ok(/escapeHtml\(p\.name\)/.test(src), 'point tooltips must escape the name'); + assert.ok(/escapeHtml\(pt\.name\)/.test(src), 'point tooltips must escape the name'); +}); + +test('draws every branch, not just the deepest one', () => { + assert.ok(/branches\.map/.test(src), 'should iterate all branches from the response'); +}); + +test('draws the deepest branch on top of the others (primary drawn last)', () => { + assert.ok(/a\.primary \? 1 : 0/.test(src) || /primary.*sort/.test(src), 'should reorder so the primary branch paints last'); }); test('handles Escape key and click-outside to close, matching other CoreScope modals', () => { @@ -140,21 +148,24 @@ function makeSandbox(apiImpl) { await (async () => { try { - const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', hops: 0, points: [] })); + const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', branches: [] })); await ctx.window.PacketPathMap.open('deadbeef'); const status = ctx.document.getElementById('packetPathStatus'); - assert.ok(status.textContent.includes('no resolved relay path'), 'should explain there is nothing to show yet, got: ' + status.textContent); + assert.ok(status.textContent.includes('no observations'), 'should explain there is nothing to show yet, got: ' + status.textContent); passed++; - console.log(' ✅ an empty path (hops=0, no points) shows a clear "nothing to show" status'); - } catch (e) { failed++; console.log(' ❌ an empty path (hops=0, no points) shows a clear "nothing to show" status: ' + e.message); } + console.log(' ✅ no branches at all shows a clear "nothing to show" status'); + } catch (e) { failed++; console.log(' ❌ no branches at all shows a clear "nothing to show" status: ' + e.message); } })(); await (async () => { try { - const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', hops: 3, points: [{ publicKey: 'pk1', name: 'RepeaterA', lat: null, lon: null }] })); + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [{ hops: 3, points: [{ publicKey: 'pk1', name: 'RepeaterA', lat: null, lon: null }], observer: null }], + })); await ctx.window.PacketPathMap.open('deadbeef'); const status = ctx.document.getElementById('packetPathStatus'); - assert.ok(status.textContent.includes('3 hop'), 'should mention the hop count even when no hop has a known position, got: ' + status.textContent); + assert.ok(status.textContent.includes('3 hop'), 'should mention the hop count even when no branch has a known position, got: ' + status.textContent); passed++; console.log(' ✅ hops with no known position at all still report the hop count, not a silent blank'); } catch (e) { failed++; console.log(' ❌ hops with no known position at all still report the hop count, not a silent blank: ' + e.message); } @@ -172,6 +183,52 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ close() removes the modal overlay from the DOM: ' + e.message); } })(); + await (async () => { + try { + // Two branches: a 2-hop chain (deepest, drawn primary) and a + // 0-hop direct observer with no resolvable relay names at all. + // Both should still get plotted -- this is the whole point of the + // "show every branch" rework (a station that heard the packet + // directly is real reach data even without a relay chain). + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [ + { + hops: 2, + points: [ + { publicKey: 'pk1', name: 'RepeaterA', lat: 56.0, lon: 10.0 }, + { publicKey: 'pk2', name: 'RepeaterB', lat: 56.1, lon: 10.1 }, + ], + observer: { name: 'FarObserver', lat: 56.2, lon: 10.2 }, + }, + { hops: 0, points: [], observer: { name: 'NearObserver', lat: 55.9, lon: 9.9 } }, + ], + })); + + let markerCount = 0, polylineCount = 0; + ctx.L = { + map: () => ({ + setView() { return this; }, + fitBounds() {}, + invalidateSize() {}, + remove() {}, + }), + tileLayer: () => ({ addTo() { return this; } }), + circleMarker: () => { markerCount++; return { addTo() { return this; }, bindTooltip() { return this; } }; }, + polyline: () => { polylineCount++; return { addTo() { return this; } }; }, + }; + + await ctx.window.PacketPathMap.open('deadbeef'); + const status = ctx.document.getElementById('packetPathStatus'); + assert.strictEqual(markerCount, 4, 'expected 4 markers: 2 hops + observer for branch 1, 1 observer-only point for branch 2, got ' + markerCount); + assert.strictEqual(polylineCount, 1, 'expected exactly 1 polyline (only the 3-point branch has >1 point to connect), got ' + polylineCount); + assert.ok(status.textContent.includes('2 of 2 stations shown'), 'status should report both branches plotted, got: ' + status.textContent); + assert.ok(status.textContent.includes('deepest reached 2 hop'), 'status should report the deepest branch hop count, got: ' + status.textContent); + passed++; + console.log(' ✅ multiple branches (including a 0-hop direct observer) are all plotted, not just the deepest'); + } catch (e) { failed++; console.log(' ❌ multiple branches (including a 0-hop direct observer) are all plotted, not just the deepest: ' + e.message); } + })(); + console.log('\n════════════════════════════════════════'); console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`); console.log('════════════════════════════════════════');